├── .flake8 ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── matplotlib_pyodide ├── __init__.py ├── browser_backend.py ├── html5_canvas_backend.py └── wasm_backend.py ├── pyproject.toml └── tests ├── conftest.py ├── test_data ├── canvas-chrome.png ├── canvas-custom-font-text-chrome.png ├── canvas-custom-font-text-firefox.png ├── canvas-custom-font-text-safari.png ├── canvas-firefox.png ├── canvas-image-affine-chrome.png ├── canvas-image-affine-firefox.png ├── canvas-image-affine-safari.png ├── canvas-image-chrome.png ├── canvas-image-firefox.png ├── canvas-image-safari.png ├── canvas-math-text-chrome.png ├── canvas-math-text-firefox.png ├── canvas-math-text-safari.png ├── canvas-polar-zoom-chrome.png ├── canvas-polar-zoom-firefox.png ├── canvas-polar-zoom-safari.png ├── canvas-safari.png ├── canvas-text-rotated-chrome.png ├── canvas-text-rotated-firefox.png ├── canvas-text-rotated-safari.png ├── canvas-transparency-chrome.png ├── canvas-transparency-firefox.png └── canvas-transparency-safari.png ├── test_html5_canvas_backend.py └── test_matplotlib.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E203, E402, E501, E731, E741, B950, W503 3 | select = C,E,F,W,B,B9 4 | extend-immutable-calls = typer.Argument, typer.Option 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 3 7 | 8 | permissions: 9 | contents: read 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ${{ matrix.os }} 18 | env: 19 | DISPLAY: :99 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [ubuntu-latest] 24 | pyodide-version: [0.26.4] 25 | test-config: 26 | [ 27 | # FIXME: timeouts on recent versions of Chrome, same as micropip 28 | { runner: selenium, runtime: chrome, runtime-version: 125 }, 29 | { runner: selenium, runtime: firefox, runtime-version: latest }, 30 | ] 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | 37 | - uses: actions/setup-python@v5 38 | with: 39 | python-version: 3.12 40 | 41 | - uses: pyodide/pyodide-actions/download-pyodide@v1 42 | with: 43 | version: ${{ matrix.pyodide-version }} 44 | to: dist 45 | 46 | - uses: pyodide/pyodide-actions/install-browser@v2 47 | with: 48 | runner: ${{ matrix.test-config.runner }} 49 | browser: ${{ matrix.test-config.runtime }} 50 | browser-version: ${{ matrix.test-config.runtime-version }} 51 | 52 | - name: Install requirements 53 | shell: bash -l {0} 54 | run: | 55 | python3 -m pip install -e .[test] 56 | 57 | - name: Run tests 58 | shell: bash -l {0} 59 | run: | 60 | pytest -v \ 61 | --cov=matplotlib_pyodide \ 62 | --dist-dir=./dist/ \ 63 | --runner=${{ matrix.test-config.runner }} \ 64 | --rt ${{ matrix.test-config.runtime }} 65 | - uses: codecov/codecov-action@v3 66 | if: ${{ github.event.repo.name == 'pyodide/matplotlib-pyodide' || github.event_name == 'pull_request' }} 67 | with: 68 | fail_ci_if_error: false 69 | deploy: 70 | runs-on: ubuntu-latest 71 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 72 | environment: PyPi-deploy 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - uses: actions/setup-python@v5 77 | with: 78 | python-version: 3.12 79 | - name: Install requirements and build wheel 80 | shell: bash -l {0} 81 | run: | 82 | python -m pip install build twine 83 | python -m build . 84 | - name: Publish package 85 | uses: pypa/gh-action-pypi-publish@release/v1 86 | with: 87 | user: __token__ 88 | password: ${{ secrets.PYPI_API_TOKEN }} 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build-related files 2 | /build 3 | /matplotlib_pyodide.egg-info 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: "3.11" 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: "v4.4.0" 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-case-conflict 9 | - id: check-merge-conflict 10 | - id: check-symlinks 11 | - id: check-yaml 12 | exclude: .clang-format 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | - id: mixed-line-ending 16 | - id: trailing-whitespace 17 | 18 | - repo: https://github.com/PyCQA/isort 19 | rev: "5.12.0" 20 | hooks: 21 | - id: isort 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: "v3.13.0" 25 | hooks: 26 | - id: pyupgrade 27 | args: ["--py310-plus"] 28 | 29 | - repo: https://github.com/hadialqattan/pycln 30 | rev: "v2.2.2" 31 | hooks: 32 | - id: pycln 33 | args: [--config=pyproject.toml] 34 | stages: [manual] 35 | 36 | - repo: https://github.com/psf/black 37 | rev: "23.9.1" 38 | hooks: 39 | - id: black 40 | 41 | - repo: https://github.com/pycqa/flake8 42 | rev: "6.1.0" 43 | hooks: 44 | - id: flake8 45 | additional_dependencies: [flake8-bugbear] 46 | 47 | - repo: https://github.com/pre-commit/mirrors-mypy 48 | rev: "v1.5.1" 49 | hooks: 50 | - id: mypy 51 | args: [] 52 | additional_dependencies: &mypy-deps 53 | - pytest 54 | 55 | 56 | - repo: https://github.com/codespell-project/codespell 57 | rev: "v2.2.5" 58 | hooks: 59 | - id: codespell 60 | args: ["-L", "te,slowy,aray,ba,nd,classs,crate,feld,lits"] 61 | ci: 62 | autoupdate_schedule: "quarterly" 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.2] - 2024-03-04 10 | ### Fixed 11 | - Add FigureCanvasWasm.destroy() method so that user can call pyplot.close() method to delete previous divs 12 | ([#55](https://github.com/pyodide/matplotlib-pyodide/pull/55)) 13 | 14 | ## [0.2.1] - 2023-10-06 15 | ### Fixed 16 | - Improved support for matplotlib canvas methods 17 | ([#42](https://github.com/pyodide/matplotlib-pyodide/pull/42)) 18 | 19 | ## [0.2.0] - 2023-08-28 20 | ### Added 21 | - Added a feature to specify where the plot will be rendered 22 | ([#22](https://github.com/pyodide/pyodide-cli/pull/22)) 23 | 24 | ## [0.1.1] - 2022-09-03 25 | ### Fixed 26 | - Fix import path issue 27 | ([#4](https://github.com/pyodide/pyodide-cli/pull/4)) 28 | 29 | 30 | ## [0.1.0] - 2022-09-02 31 | 32 | Initial release 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DISCLAIMER 2 | 3 | This project is no longer used in Pyodide as of Pyodide v0.28 (see [issue#65](https://github.com/pyodide/matplotlib-pyodide/issues/65#issuecomment-2532463697)). 4 | We don't accept any new features or bug fixes. The project is archived and will not be maintained anymore. 5 | 6 | The default matplotlib backend for Pyodide is now the patched version of `webagg` backend. If you were using `matplotlib_pyodide` in your code, 7 | simply removing the `matplotlib.use('module://matplotlib_pyodide...')` line should be enough to make your code work with the new backend. 8 | 9 | If it doesn't, try replacing it with `matplotlib.use('webagg')`. 10 | 11 | # matplotlib-pyodide 12 | 13 | [![PyPI Latest Release](https://img.shields.io/pypi/v/matplotlib-pyodide.svg)](https://pypi.org/project/matplotlib-pyodide/) 14 | ![GHA](https://github.com/pyodide/matplotlib-pyodide/actions/workflows/main.yml/badge.svg) 15 | [![codecov](https://codecov.io/gh/pyodide/matplotlib-pyodide/branch/main/graph/badge.svg)](https://codecov.io/gh/pyodide/matplotlib-pyodide) 16 | 17 | 18 | HTML5 backends for Matplotlib compatible with Pyodide 19 | 20 | This package includes two matplotlib backends, 21 | 22 | - the `wasm_backend` which from allows rendering the Agg buffer as static images into an HTML canvas 23 | - an interactive HTML5 canvas backend `html5_canvas_backend` described in 24 | [this blog post](https://blog.pyodide.org/posts/canvas-renderer-matplotlib-in-pyodide/) 25 | 26 | 27 | ## Installation 28 | 29 | This package will be installed as a dependency when you load `matplotlib` in Pyodide. 30 | 31 | ## Usage 32 | 33 | To change the backend in matplotlib, 34 | - for the wasm backend, 35 | ```py 36 | import matplotlib 37 | matplotlib.use("module://matplotlib_pyodide.wasm_backend") 38 | ``` 39 | - for the interactive HTML5 backend; 40 | ```py 41 | import matplotlib 42 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 43 | ``` 44 | 45 | By default, matplotlib figures will be rendered inside a div that's appended to the end of `document.body`. 46 | You can override this behavior by setting `document.pyodideMplTarget` to an HTML element. If you had an HTML 47 | element with id "target", you could configure the backend to render visualizations inside it with this code: 48 | 49 | ```py 50 | document.pyodideMplTarget = document.getElementById('target') 51 | ``` 52 | 53 | For more information see the [matplotlib documentation](https://matplotlib.org/stable/users/explain/backends.html). 54 | 55 | ## License 56 | 57 | pyodide-cli uses the [Mozilla Public License Version 58 | 2.0](https://choosealicense.com/licenses/mpl-2.0/). 59 | -------------------------------------------------------------------------------- /matplotlib_pyodide/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version("matplotlib_pyodide") 5 | except PackageNotFoundError: 6 | # package is not installed 7 | pass 8 | -------------------------------------------------------------------------------- /matplotlib_pyodide/browser_backend.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from js import document 4 | from matplotlib.backend_bases import FigureCanvasBase, NavigationToolbar2, TimerBase 5 | 6 | from pyodide.ffi.wrappers import ( 7 | add_event_listener, 8 | clear_interval, 9 | clear_timeout, 10 | set_interval, 11 | set_timeout, 12 | ) 13 | 14 | try: 15 | from js import devicePixelRatio as DEVICE_PIXEL_RATIO 16 | except ImportError: 17 | DEVICE_PIXEL_RATIO = 1 18 | 19 | 20 | class FigureCanvasWasm(FigureCanvasBase): 21 | supports_blit = False 22 | 23 | def __init__(self, *args, **kwargs): 24 | super().__init__(*args, **kwargs) 25 | 26 | self._idle_scheduled = False 27 | self._id = "matplotlib_" + hex(id(self))[2:] 28 | self._title = "" 29 | self._ratio = 1 30 | matplotlib_figure_styles = self._add_matplotlib_styles() 31 | if document.getElementById("matplotlib-figure-styles") is None: 32 | document.head.appendChild(matplotlib_figure_styles) 33 | 34 | def _add_matplotlib_styles(self): 35 | toolbar_buttons_css_content = """ 36 | button.matplotlib-toolbar-button { 37 | font-size: 14px; 38 | color: #495057; 39 | text-transform: uppercase; 40 | background: #e9ecef; 41 | padding: 9px 18px; 42 | border: 1px solid #fff; 43 | border-radius: 4px; 44 | transition-duration: 0.4s; 45 | } 46 | 47 | button.matplotlib-toolbar-button#text { 48 | font-family: -apple-system, BlinkMacSystemFont, 49 | "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, 50 | "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, 51 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 52 | "Segoe UI Symbol"; 53 | } 54 | 55 | button.matplotlib-toolbar-button:hover { 56 | color: #fff; 57 | background: #495057; 58 | } 59 | """ 60 | toolbar_buttons_style_element = document.createElement("style") 61 | toolbar_buttons_style_element.id = "matplotlib-figure-styles" 62 | toolbar_buttons_css = document.createTextNode(toolbar_buttons_css_content) 63 | toolbar_buttons_style_element.appendChild(toolbar_buttons_css) 64 | return toolbar_buttons_style_element 65 | 66 | def get_element(self, name): 67 | """ 68 | Looks up an HTMLElement created for this figure. 69 | """ 70 | # TODO: Should we store a reference here instead of always looking it 71 | # up? I'm a little concerned about weird Python/JS 72 | # cross-memory-management issues... 73 | return document.getElementById(self._id + name) 74 | 75 | def get_dpi_ratio(self, context): 76 | """ 77 | Gets the ratio of physical pixels to logical pixels for the given HTML 78 | Canvas context. 79 | 80 | This is typically 2 on a HiDPI ("Retina") display, and 1 otherwise. 81 | """ 82 | backing_store = ( 83 | getattr(context, "backingStorePixelRatio", 0) 84 | or getattr(context, "webkitBackingStorePixel", 0) 85 | or getattr(context, "mozBackingStorePixelRatio", 0) 86 | or getattr(context, "msBackingStorePixelRatio", 0) 87 | or getattr(context, "oBackingStorePixelRatio", 0) 88 | or getattr(context, "backendStorePixelRatio", 0) 89 | or 1 90 | ) 91 | return DEVICE_PIXEL_RATIO / backing_store 92 | 93 | def show(self, *args, **kwargs): 94 | # If we've already shown this canvas elsewhere, don't create a new one, 95 | # just reuse it and scroll to the existing one. 96 | existing = self.get_element("") 97 | if existing is not None: 98 | self.draw_idle() 99 | existing.scrollIntoView() 100 | return 101 | 102 | # Disable the right-click context menu. 103 | # Doesn't work in all browsers. 104 | def ignore(event): 105 | event.preventDefault() 106 | return False 107 | 108 | # Create the main canvas and determine the physical to logical pixel 109 | # ratio 110 | canvas = document.createElement("canvas") 111 | context = canvas.getContext("2d") 112 | self._ratio = self.get_dpi_ratio(context) 113 | 114 | width, height = self.get_width_height() 115 | width *= self._ratio 116 | height *= self._ratio 117 | div = self._create_root_element() 118 | add_event_listener(div, "contextmenu", ignore) 119 | div.setAttribute( 120 | "style", 121 | "margin: 0 auto; text-align: center;" + f"width: {width / self._ratio}px", 122 | ) 123 | div.id = self._id 124 | 125 | # The top bar 126 | top = document.createElement("div") 127 | top.id = self._id + "top" 128 | top.setAttribute("style", "font-weight: bold; text-align: center") 129 | top.textContent = self._title 130 | div.appendChild(top) 131 | 132 | # A div containing two canvases stacked on top of one another: 133 | # - The bottom for rendering matplotlib content 134 | # - The top for rendering interactive elements, such as the zoom 135 | # rubberband 136 | canvas_div = document.createElement("div") 137 | canvas_div.setAttribute("style", "position: relative") 138 | 139 | canvas.id = self._id + "canvas" 140 | canvas.setAttribute("width", width) 141 | canvas.setAttribute("height", height) 142 | canvas.setAttribute( 143 | "style", 144 | "left: 0; top: 0; z-index: 0; outline: 0;" 145 | + "width: {}px; height: {}px".format( 146 | width / self._ratio, height / self._ratio 147 | ), 148 | ) 149 | canvas_div.appendChild(canvas) 150 | 151 | rubberband = document.createElement("canvas") 152 | rubberband.id = self._id + "rubberband" 153 | rubberband.setAttribute("width", width) 154 | rubberband.setAttribute("height", height) 155 | rubberband.setAttribute( 156 | "style", 157 | "position: absolute; left: 0; top: 0; z-index: 0; " 158 | + "outline: 0; width: {}px; height: {}px".format( 159 | width / self._ratio, height / self._ratio 160 | ), 161 | ) 162 | # Canvas must have a "tabindex" attr in order to receive keyboard 163 | # events 164 | rubberband.setAttribute("tabindex", "0") 165 | # Event handlers are added to the canvas "on top", even though most of 166 | # the activity happens in the canvas below. 167 | # TODO: with 0.2.3, we temporarily disable event listeners for the rubberband canvas. 168 | # This shall be revisited in a future release. 169 | # add_event_listener(rubberband, "mousemove", self.onmousemove) 170 | # add_event_listener(rubberband, "mouseup", self.onmouseup) 171 | # add_event_listener(rubberband, "mousedown", self.onmousedown) 172 | # add_event_listener(rubberband, "mouseenter", self.onmouseenter) 173 | # add_event_listener(rubberband, "mouseleave", self.onmouseleave) 174 | # add_event_listener(rubberband, "keyup", self.onkeyup) 175 | # add_event_listener(rubberband, "keydown", self.onkeydown) 176 | context = rubberband.getContext("2d") 177 | context.strokeStyle = "#000000" 178 | context.setLineDash([2, 2]) 179 | canvas_div.appendChild(rubberband) 180 | 181 | div.appendChild(canvas_div) 182 | 183 | # The bottom bar, with toolbar and message display 184 | bottom = document.createElement("div") 185 | 186 | # Check if toolbar exists before trying to get its element 187 | # c.f. https://github.com/pyodide/pyodide/pull/4510 188 | if self.toolbar is not None: 189 | toolbar = self.toolbar.get_element() 190 | bottom.appendChild(toolbar) 191 | 192 | message = document.createElement("div") 193 | message.id = self._id + "message" 194 | message.setAttribute("style", "min-height: 1.5em") 195 | bottom.appendChild(message) 196 | div.appendChild(bottom) 197 | 198 | self.draw() 199 | 200 | def destroy(self, *args, **kwargs): 201 | div = document.getElementById(self._id) 202 | parentElement = div.parentNode 203 | if parentElement: 204 | parentElement.removeChild(div) 205 | div.removeChild(div.firstChild) 206 | 207 | def draw(self): 208 | pass 209 | 210 | def draw_idle(self): 211 | if not self._idle_scheduled: 212 | self._idle_scheduled = True 213 | set_timeout(self.draw, 1) 214 | 215 | def set_message(self, message): 216 | message_display = self.get_element("message") 217 | if message_display is not None: 218 | message_display.textContent = message 219 | 220 | def _convert_mouse_event(self, event): 221 | width, height = self.get_width_height() 222 | x = event.offsetX 223 | y = height - event.offsetY 224 | button = event.button + 1 225 | # Disable the right-click context menu in some browsers 226 | if button == 3: 227 | event.preventDefault() 228 | event.stopPropagation() 229 | if button == 2: 230 | button = 3 231 | return x, y, button 232 | 233 | def onmousemove(self, event): 234 | x, y, button = self._convert_mouse_event(event) 235 | self.motion_notify_event(x, y, guiEvent=event) 236 | 237 | def onmouseup(self, event): 238 | x, y, button = self._convert_mouse_event(event) 239 | self.button_release_event(x, y, button, guiEvent=event) 240 | 241 | def onmousedown(self, event): 242 | x, y, button = self._convert_mouse_event(event) 243 | self.button_press_event(x, y, button, guiEvent=event) 244 | 245 | def onmouseenter(self, event): 246 | # When the mouse is over the figure, get keyboard focus 247 | self.get_element("rubberband").focus() 248 | self.enter_notify_event(guiEvent=event) 249 | 250 | def onmouseleave(self, event): 251 | # When the mouse leaves the figure, drop keyboard focus 252 | self.get_element("rubberband").blur() 253 | self.leave_notify_event(guiEvent=event) 254 | 255 | def onscroll(self, event): 256 | x, y, button = self._convert_mouse_event(event) 257 | self.scroll_event(x, y, event.deltaX, guiEvent=event) 258 | 259 | _cursor_map = {0: "pointer", 1: "default", 2: "crosshair", 3: "move"} 260 | 261 | def set_cursor(self, cursor): 262 | rubberband = self.get_element("rubberband") 263 | if rubberband is not None: 264 | rubberband.style.cursor = self._cursor_map.get(cursor, 0) 265 | 266 | # http://www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes 267 | _SHIFT_LUT = { 268 | 59: ":", 269 | 61: "+", 270 | 173: "_", 271 | 186: ":", 272 | 187: "+", 273 | 188: "<", 274 | 189: "_", 275 | 190: ">", 276 | 191: "?", 277 | 192: "~", 278 | 219: "{", 279 | 220: "|", 280 | 221: "}", 281 | 222: '"', 282 | } 283 | 284 | _LUT = { 285 | 8: "backspace", 286 | 9: "tab", 287 | 13: "enter", 288 | 16: "shift", 289 | 17: "control", 290 | 18: "alt", 291 | 19: "pause", 292 | 20: "caps", 293 | 27: "escape", 294 | 32: " ", 295 | 33: "pageup", 296 | 34: "pagedown", 297 | 35: "end", 298 | 36: "home", 299 | 37: "left", 300 | 38: "up", 301 | 39: "right", 302 | 40: "down", 303 | 45: "insert", 304 | 46: "delete", 305 | 91: "super", 306 | 92: "super", 307 | 93: "select", 308 | 106: "*", 309 | 107: "+", 310 | 109: "-", 311 | 110: ".", 312 | 111: "/", 313 | 144: "num_lock", 314 | 145: "scroll_lock", 315 | 186: ":", 316 | 187: "=", 317 | 188: ",", 318 | 189: "-", 319 | 190: ".", 320 | 191: "/", 321 | 192: "`", 322 | 219: "[", 323 | 220: "\\", 324 | 221: "]", 325 | 222: "'", 326 | } 327 | 328 | def _create_root_element(self): 329 | div = document.createElement("div") 330 | mpl_target = getattr(document, "pyodideMplTarget", document.body) 331 | mpl_target.appendChild(div) 332 | return div 333 | 334 | def _convert_key_event(self, event): 335 | code = int(event.which) 336 | value = chr(code) 337 | shift = event.shiftKey and code != 16 338 | ctrl = event.ctrlKey and code != 17 339 | alt = event.altKey and code != 18 340 | 341 | # letter keys 342 | if 65 <= code <= 90: 343 | if not shift: 344 | value = value.lower() 345 | else: 346 | shift = False 347 | # number keys 348 | elif 48 <= code <= 57: 349 | if shift: 350 | value = ")!@#$%^&*("[int(value)] 351 | shift = False 352 | # function keys 353 | elif 112 <= code <= 123: 354 | value = "f%s" % (code - 111) 355 | # number pad keys 356 | elif 96 <= code <= 105: 357 | value = "%s" % (code - 96) 358 | # keys with shift alternatives 359 | elif code in self._SHIFT_LUT and shift: 360 | value = self._SHIFT_LUT[code] 361 | shift = False 362 | elif code in self._LUT: 363 | value = self._LUT[code] 364 | 365 | key = [] 366 | if shift: 367 | key.append("shift") 368 | if ctrl: 369 | key.append("ctrl") 370 | if alt: 371 | key.append("alt") 372 | key.append(value) 373 | return "+".join(key) 374 | 375 | def onkeydown(self, event): 376 | key = self._convert_key_event(event) 377 | self.key_press_event(key, guiEvent=event) 378 | 379 | def onkeyup(self, event): 380 | key = self._convert_key_event(event) 381 | self.key_release_event(key, guiEvent=event) 382 | 383 | def get_window_title(self): 384 | top = self.get_element("top") 385 | return top.textContent 386 | 387 | def set_window_title(self, title): 388 | top = self.get_element("top") 389 | self._title = title 390 | if top is not None: 391 | top.textContent = title 392 | 393 | # def resize_event(self): 394 | # # TODO 395 | # pass 396 | 397 | # def close_event(self): 398 | # # TODO 399 | # pass 400 | 401 | def draw_rubberband(self, x0, y0, x1, y1): 402 | rubberband = self.get_element("rubberband") 403 | width, height = self.get_width_height() 404 | y0 = height - y0 405 | y1 = height - y1 406 | x0 = math.floor(x0) + 0.5 407 | y0 = math.floor(y0) + 0.5 408 | x1 = math.floor(x1) + 0.5 409 | y1 = math.floor(y1) + 0.5 410 | if x1 < x0: 411 | x0, x1 = x1, x0 412 | if y1 < y0: 413 | y0, y1 = y1, y0 414 | context = rubberband.getContext("2d") 415 | context.clearRect(0, 0, width * self._ratio, height * self._ratio) 416 | context.strokeRect( 417 | x0 * self._ratio, 418 | y0 * self._ratio, 419 | (x1 - x0) * self._ratio, 420 | (y1 - y0) * self._ratio, 421 | ) 422 | 423 | def remove_rubberband(self): 424 | rubberband = self.get_element("rubberband") 425 | width, height = self.get_width_height() 426 | context = rubberband.getContext("2d") 427 | context.clearRect(0, 0, width * self._ratio, height * self._ratio) 428 | 429 | def new_timer(self, *args, **kwargs): 430 | return TimerWasm(*args, **kwargs) 431 | 432 | 433 | _FONTAWESOME_ICONS = { 434 | "home": "fa-home", 435 | "back": "fa-arrow-left", 436 | "forward": "fa-arrow-right", 437 | "zoom_to_rect": "fa-search-plus", 438 | "move": "fa-arrows", 439 | "download": "fa-download", 440 | None: None, 441 | } 442 | 443 | 444 | FILE_TYPES = {"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"} 445 | 446 | 447 | class NavigationToolbar2Wasm(NavigationToolbar2): 448 | def _init_toolbar(self): 449 | pass 450 | 451 | def get_element(self): 452 | # Create the HTML content for the toolbar 453 | div = document.createElement("span") 454 | 455 | def add_spacer(): 456 | span = document.createElement("span") 457 | span.style.minWidth = "16px" 458 | span.textContent = "\u00a0" 459 | div.appendChild(span) 460 | 461 | for _text, _tooltip_text, image_file, name_of_method in self.toolitems: 462 | if image_file in _FONTAWESOME_ICONS: 463 | if image_file is None: 464 | add_spacer() 465 | else: 466 | button = document.createElement("button") 467 | button.classList.add("fa") 468 | button.classList.add(_FONTAWESOME_ICONS[image_file]) 469 | button.classList.add("matplotlib-toolbar-button") 470 | add_event_listener(button, "click", getattr(self, name_of_method)) 471 | div.appendChild(button) 472 | 473 | for format, _mimetype in sorted(list(FILE_TYPES.items())): 474 | button = document.createElement("button") 475 | button.classList.add("fa") 476 | button.textContent = format 477 | button.classList.add("matplotlib-toolbar-button") 478 | button.id = "text" 479 | add_event_listener(button, "click", self.ondownload) 480 | div.appendChild(button) 481 | 482 | return div 483 | 484 | def ondownload(self, event): 485 | format = event.target.textContent 486 | self.download(format, FILE_TYPES[format]) 487 | 488 | def download(self, format, mimetype): 489 | pass 490 | 491 | def set_message(self, message): 492 | self.canvas.set_message(message) 493 | 494 | def set_cursor(self, cursor): 495 | self.canvas.set_cursor(cursor) 496 | 497 | def draw_rubberband(self, event, x0, y0, x1, y1): 498 | self.canvas.draw_rubberband(x0, y0, x1, y1) 499 | 500 | def remove_rubberband(self): 501 | self.canvas.remove_rubberband() 502 | 503 | 504 | class TimerWasm(TimerBase): 505 | def _timer_start(self): 506 | self._timer_stop() 507 | if self._single: 508 | self._timer: int | None = set_timeout(self._on_timer, self.interval) 509 | else: 510 | self._timer = set_interval(self._on_timer, self.interval) 511 | 512 | def _timer_stop(self): 513 | if self._timer is None: 514 | return 515 | elif self._single: 516 | clear_timeout(self._timer) 517 | self._timer = None 518 | else: 519 | clear_interval(self._timer) 520 | self._timer = None 521 | 522 | def _timer_set_interval(self): 523 | # Only stop and restart it if the timer has already been started 524 | if self._timer is not None: 525 | self._timer_stop() 526 | self._timer_start() 527 | -------------------------------------------------------------------------------- /matplotlib_pyodide/html5_canvas_backend.py: -------------------------------------------------------------------------------- 1 | # 2 | # HTMl5 Canvas backend for Matplotlib to use when running Matplotlib in Pyodide, first 3 | # introduced via a Google Summer of Code 2019 project: 4 | # https://summerofcode.withgoogle.com/archive/2019/projects/4683094261497856 5 | # 6 | # Associated blog post: 7 | # https://blog.pyodide.org/posts/canvas-renderer-matplotlib-in-pyodide 8 | # 9 | # TODO: As of release 0.2.3, this backend is not yet fully functional following 10 | # an update from Matplotlib 3.5.2 to 3.8.4 in Pyodide in-tree, please refer to 11 | # https://github.com/pyodide/pyodide/pull/4510. 12 | # 13 | # This backend has been redirected to use the WASM backend in the meantime, which 14 | # is now fully functional. The source code for the HTML5 Canvas backend is still 15 | # available in this file, and shall be updated to work in a future release. 16 | # 17 | # Readers are advised to look at https://github.com/pyodide/matplotlib-pyodide/issues/64 18 | # and at https://github.com/pyodide/matplotlib-pyodide/pull/65 for information 19 | # around the status of this backend and on how to contribute to its restoration 20 | # for future releases. Thank you! 21 | 22 | import base64 23 | import io 24 | import math 25 | from functools import lru_cache 26 | 27 | import matplotlib.pyplot as plt 28 | import numpy as np 29 | from matplotlib import __version__, figure, interactive 30 | from matplotlib._enums import CapStyle 31 | from matplotlib.backend_bases import ( 32 | FigureManagerBase, 33 | GraphicsContextBase, 34 | RendererBase, 35 | _Backend, 36 | ) 37 | from matplotlib.backends import backend_agg 38 | from matplotlib.colors import colorConverter, rgb2hex 39 | from matplotlib.font_manager import findfont 40 | from matplotlib.ft2font import LOAD_NO_HINTING, FT2Font 41 | from matplotlib.mathtext import MathTextParser 42 | from matplotlib.path import Path 43 | from matplotlib.transforms import Affine2D 44 | from PIL import Image 45 | from PIL.PngImagePlugin import PngInfo 46 | 47 | # Redirect to the WASM backend 48 | from matplotlib_pyodide.browser_backend import FigureCanvasWasm, NavigationToolbar2Wasm 49 | from matplotlib_pyodide.wasm_backend import FigureCanvasAggWasm, FigureManagerAggWasm 50 | 51 | try: 52 | from js import FontFace, ImageData, document 53 | except ImportError as err: 54 | raise ImportError( 55 | "html5_canvas_backend is only supported in the browser in the main thread" 56 | ) from err 57 | 58 | from pyodide.ffi import create_proxy 59 | 60 | _capstyle_d = {"projecting": "square", "butt": "butt", "round": "round"} 61 | 62 | # The URLs of fonts that have already been loaded into the browser 63 | _font_set = set() 64 | 65 | _base_fonts_url = "/fonts/" 66 | 67 | interactive(True) 68 | 69 | 70 | class FigureCanvasHTMLCanvas(FigureCanvasWasm): 71 | def __init__(self, *args, **kwargs): 72 | FigureCanvasWasm.__init__(self, *args, **kwargs) 73 | 74 | def draw(self): 75 | # Render the figure using custom renderer 76 | self._idle_scheduled = True 77 | orig_dpi = self.figure.dpi 78 | if self._ratio != 1: 79 | self.figure.dpi *= self._ratio 80 | try: 81 | width, height = self.get_width_height() 82 | canvas = self.get_element("canvas") 83 | if canvas is None: 84 | return 85 | ctx = canvas.getContext("2d") 86 | renderer = RendererHTMLCanvas(ctx, width, height, self.figure.dpi, self) 87 | self.figure.draw(renderer) 88 | except Exception as e: 89 | raise RuntimeError("Rendering failed") from e 90 | finally: 91 | self.figure.dpi = orig_dpi 92 | self._idle_scheduled = False 93 | 94 | def get_pixel_data(self): 95 | """ 96 | Directly getting the underlying pixel data (using `getImageData()`) 97 | results in a different (but similar) image than the reference image. 98 | The method below takes a longer route 99 | (pixels --> encode PNG --> decode PNG --> pixels) 100 | but gives us the exact pixel data that the reference image has allowing 101 | us to do a fair comparison test. 102 | """ 103 | canvas = self.get_element("canvas") 104 | img_URL = canvas.toDataURL("image/png")[21:] 105 | canvas_base64 = base64.b64decode(img_URL) 106 | return np.asarray(Image.open(io.BytesIO(canvas_base64))) 107 | 108 | def print_png( 109 | self, filename_or_obj, *args, metadata=None, pil_kwargs=None, **kwargs 110 | ): 111 | if metadata is None: 112 | metadata = {} 113 | if pil_kwargs is None: 114 | pil_kwargs = {} 115 | metadata = { 116 | "Software": f"matplotlib version{__version__}, http://matplotlib.org/", 117 | **metadata, 118 | } 119 | 120 | if "pnginfo" not in pil_kwargs: 121 | pnginfo = PngInfo() 122 | for k, v in metadata.items(): 123 | pnginfo.add_text(k, v) 124 | pil_kwargs["pnginfo"] = pnginfo 125 | pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) 126 | 127 | data = self.get_pixel_data() 128 | 129 | (Image.fromarray(data).save(filename_or_obj, format="png", **pil_kwargs)) 130 | 131 | 132 | class NavigationToolbar2HTMLCanvas(NavigationToolbar2Wasm): 133 | def download(self, format, mimetype): 134 | """ 135 | Creates a temporary `a` element with a URL containing the image 136 | content, and then virtually clicks it. Kind of magical, but it 137 | works... 138 | """ 139 | element = document.createElement("a") 140 | data = io.BytesIO() 141 | 142 | if format == "png": 143 | FigureCanvasHTMLCanvas.print_png(self.canvas, data) 144 | else: 145 | try: 146 | self.canvas.figure.savefig(data, format=format) 147 | except Exception: 148 | raise 149 | 150 | element.setAttribute( 151 | "href", 152 | "data:{};base64,{}".format( 153 | mimetype, base64.b64encode(data.getvalue()).decode("ascii") 154 | ), 155 | ) 156 | element.setAttribute("download", f"plot.{format}") 157 | element.style.display = "none" 158 | 159 | document.body.appendChild(element) 160 | element.click() 161 | document.body.removeChild(element) 162 | 163 | 164 | class GraphicsContextHTMLCanvas(GraphicsContextBase): 165 | def __init__(self, renderer): 166 | super().__init__() 167 | self.stroke = True 168 | self.renderer = renderer 169 | 170 | def restore(self): 171 | self.renderer.ctx.restore() 172 | 173 | def set_capstyle(self, cs): 174 | """ 175 | Set the cap style for lines in the graphics context. 176 | 177 | Parameters 178 | ---------- 179 | cs : CapStyle or str 180 | The cap style to use. Can be a CapStyle enum value or a string 181 | that can be converted to a CapStyle. 182 | """ 183 | if isinstance(cs, str): 184 | cs = CapStyle(cs) 185 | 186 | # Convert the JoinStyle enum to its name if needed 187 | if hasattr(cs, "name"): 188 | cs = cs.name.lower() 189 | 190 | if cs in ["butt", "round", "projecting"]: 191 | self._capstyle = cs 192 | self.renderer.ctx.lineCap = _capstyle_d[cs] 193 | else: 194 | raise ValueError(f"Unrecognized cap style. Found {cs}") 195 | 196 | def get_capstyle(self): 197 | return self._capstyle 198 | 199 | def set_clip_rectangle(self, rectangle): 200 | self.renderer.ctx.save() 201 | if not rectangle: 202 | self.renderer.ctx.restore() 203 | return 204 | x, y, w, h = np.round(rectangle.bounds) 205 | self.renderer.ctx.beginPath() 206 | self.renderer.ctx.rect(x, self.renderer.height - y - h, w, h) 207 | self.renderer.ctx.clip() 208 | 209 | def set_clip_path(self, path): 210 | self.renderer.ctx.save() 211 | if not path: 212 | self.renderer.ctx.restore() 213 | return 214 | tpath, affine = path.get_transformed_path_and_affine() 215 | affine = affine + Affine2D().scale(1, -1).translate(0, self.renderer.height) 216 | self.renderer._path_helper(self.renderer.ctx, tpath, affine) 217 | self.renderer.ctx.clip() 218 | 219 | def set_dashes(self, dash_offset, dash_list): 220 | self._dashes = dash_offset, dash_list 221 | if dash_offset is not None: 222 | self.renderer.ctx.lineDashOffset = dash_offset 223 | if dash_list is None: 224 | self.renderer.ctx.setLineDash([]) 225 | else: 226 | dln = np.asarray(dash_list) 227 | dl = list(self.renderer.points_to_pixels(dln)) 228 | self.renderer.ctx.setLineDash(dl) 229 | 230 | def set_joinstyle(self, js): 231 | if js in ["miter", "round", "bevel"]: 232 | self._joinstyle = js 233 | self.renderer.ctx.lineJoin = js 234 | else: 235 | raise ValueError(f"Unrecognized join style. Found {js}") 236 | 237 | def set_linewidth(self, w): 238 | self.stroke = w != 0 239 | self._linewidth = float(w) 240 | self.renderer.ctx.lineWidth = self.renderer.points_to_pixels(float(w)) 241 | 242 | 243 | class RendererHTMLCanvas(RendererBase): 244 | def __init__(self, ctx, width, height, dpi, fig): 245 | super().__init__() 246 | self.fig = fig 247 | self.ctx = ctx 248 | self.width = width 249 | self.height = height 250 | self.ctx.width = self.width 251 | self.ctx.height = self.height 252 | self.dpi = dpi 253 | 254 | # Create path-based math text parser; as the bitmap parser 255 | # was deprecated in 3.4 and removed after 3.5 256 | self.mathtext_parser = MathTextParser("path") 257 | 258 | self._get_font_helper = lru_cache(maxsize=50)(self._get_font_helper) 259 | 260 | # Keep the state of fontfaces that are loading 261 | self.fonts_loading = {} 262 | 263 | def new_gc(self): 264 | return GraphicsContextHTMLCanvas(renderer=self) 265 | 266 | def points_to_pixels(self, points): 267 | return (points / 72.0) * self.dpi 268 | 269 | def _matplotlib_color_to_CSS(self, color, alpha, alpha_overrides, is_RGB=True): 270 | if not is_RGB: 271 | R, G, B, alpha = colorConverter.to_rgba(color) 272 | color = (R, G, B) 273 | 274 | if (len(color) == 4) and (alpha is None): 275 | alpha = color[3] 276 | 277 | if alpha is None: 278 | CSS_color = rgb2hex(color[:3]) 279 | 280 | else: 281 | R = int(color[0] * 255) 282 | G = int(color[1] * 255) 283 | B = int(color[2] * 255) 284 | if len(color) == 3 or alpha_overrides: 285 | CSS_color = f"""rgba({R:d}, {G:d}, {B:d}, {alpha:.3g})""" 286 | else: 287 | CSS_color = """rgba({:d}, {:d}, {:d}, {:.3g})""".format( 288 | R, G, B, color[3] 289 | ) 290 | 291 | return CSS_color 292 | 293 | def _math_to_rgba(self, s, prop, rgb): 294 | """Convert math text to an RGBA array using path parser and figure""" 295 | from io import BytesIO 296 | 297 | # Get the text dimensions and generate a figure 298 | # of the right rize. 299 | width, height, depth, _, _ = self.mathtext_parser.parse(s, dpi=72, prop=prop) 300 | 301 | fig = figure.Figure(figsize=(width / 72, height / 72)) 302 | 303 | # Add text to the figure 304 | # Note: depth/height gives us the baseline position 305 | fig.text(0, depth / height, s, fontproperties=prop, color=rgb) 306 | 307 | backend_agg.FigureCanvasAgg(fig) 308 | 309 | buf = BytesIO() # render to PNG 310 | fig.savefig(buf, dpi=self.dpi, format="png", transparent=True) 311 | buf.seek(0) 312 | 313 | rgba = plt.imread(buf) 314 | return rgba, depth 315 | 316 | def _draw_math_text_path(self, gc, x, y, s, prop, angle): 317 | """Draw mathematical text using paths directly on the canvas. 318 | 319 | This method renders math text by drawing the actual glyph paths 320 | onto the canvas, rather than creating a temporary image. 321 | 322 | Parameters 323 | ---------- 324 | gc : GraphicsContextHTMLCanvas 325 | The graphics context to use for drawing 326 | x, y : float 327 | The position of the text baseline in pixels 328 | s : str 329 | The text string to render 330 | prop : FontProperties 331 | The font properties to use for rendering 332 | angle : float 333 | The rotation angle in degrees 334 | """ 335 | width, height, depth, glyphs, rects = self.mathtext_parser.parse( 336 | s, dpi=self.dpi, prop=prop 337 | ) 338 | 339 | self.ctx.save() 340 | 341 | self.ctx.translate(x, self.height - y) 342 | if angle != 0: 343 | self.ctx.rotate(-math.radians(angle)) 344 | 345 | self.ctx.fillStyle = self._matplotlib_color_to_CSS( 346 | gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha() 347 | ) 348 | 349 | for font, fontsize, _, ox, oy in glyphs: 350 | self.ctx.save() 351 | self.ctx.translate(ox, -oy) 352 | 353 | font.set_size(fontsize, self.dpi) 354 | verts, codes = font.get_path() 355 | 356 | verts = verts * fontsize / font.units_per_EM 357 | 358 | path = Path(verts, codes) 359 | 360 | transform = Affine2D().scale(1.0, -1.0) 361 | self._path_helper(self.ctx, path, transform) 362 | self.ctx.fill() 363 | 364 | self.ctx.restore() 365 | 366 | for x1, y1, x2, y2 in rects: 367 | self.ctx.fillRect(x1, -y2, x2 - x1, y2 - y1) 368 | 369 | self.ctx.restore() 370 | 371 | def _draw_math_text(self, gc, x, y, s, prop, angle): 372 | """Draw mathematical text using the most appropriate method. 373 | 374 | This method tries direct path rendering first, and falls back to 375 | the image-based approach if needed. 376 | 377 | Parameters 378 | ---------- 379 | gc : GraphicsContextHTMLCanvas 380 | The graphics context to use for drawing 381 | x, y : float 382 | The position of the text baseline in pixels 383 | s : str 384 | The text string to render 385 | prop : FontProperties 386 | The font properties to use for rendering 387 | angle : float 388 | The rotation angle in degrees 389 | """ 390 | try: 391 | self._draw_math_text_path(gc, x, y, s, prop, angle) 392 | except Exception as e: 393 | # If path rendering fails, we fall back to image-based approach 394 | print(f"Path rendering failed, falling back to image: {str(e)}") 395 | 396 | rgba, depth = self._math_to_rgba(s, prop, gc.get_rgb()) 397 | 398 | angle = math.radians(angle) 399 | if angle != 0: 400 | self.ctx.save() 401 | self.ctx.translate(x, y) 402 | self.ctx.rotate(-angle) 403 | self.ctx.translate(-x, -y) 404 | 405 | self.draw_image(gc, x, -y - depth, np.flipud(rgba)) 406 | 407 | if angle != 0: 408 | self.ctx.restore() 409 | 410 | def _set_style(self, gc, rgbFace=None): 411 | if rgbFace is not None: 412 | self.ctx.fillStyle = self._matplotlib_color_to_CSS( 413 | rgbFace, gc.get_alpha(), gc.get_forced_alpha() 414 | ) 415 | 416 | capstyle = gc.get_capstyle() 417 | if capstyle: 418 | # Get the string name if it's an enum 419 | if hasattr(capstyle, "name"): 420 | capstyle = capstyle.name.lower() 421 | self.ctx.lineCap = _capstyle_d[capstyle] 422 | 423 | self.ctx.strokeStyle = self._matplotlib_color_to_CSS( 424 | gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha() 425 | ) 426 | 427 | self.ctx.lineWidth = self.points_to_pixels(gc.get_linewidth()) 428 | 429 | def _path_helper(self, ctx, path, transform, clip=None): 430 | ctx.beginPath() 431 | for points, code in path.iter_segments(transform, remove_nans=True, clip=clip): 432 | if code == Path.MOVETO: 433 | ctx.moveTo(points[0], points[1]) 434 | elif code == Path.LINETO: 435 | ctx.lineTo(points[0], points[1]) 436 | elif code == Path.CURVE3: 437 | ctx.quadraticCurveTo(*points) 438 | elif code == Path.CURVE4: 439 | ctx.bezierCurveTo(*points) 440 | elif code == Path.CLOSEPOLY: 441 | ctx.closePath() 442 | 443 | def draw_path(self, gc, path, transform, rgbFace=None): 444 | self._set_style(gc, rgbFace) 445 | if rgbFace is None and gc.get_hatch() is None: 446 | figure_clip = (0, 0, self.width, self.height) 447 | 448 | else: 449 | figure_clip = None 450 | 451 | transform += Affine2D().scale(1, -1).translate(0, self.height) 452 | self._path_helper(self.ctx, path, transform, figure_clip) 453 | 454 | if rgbFace is not None: 455 | self.ctx.fill() 456 | self.ctx.fillStyle = "#000000" 457 | 458 | if gc.stroke: 459 | self.ctx.stroke() 460 | 461 | def draw_markers(self, gc, marker_path, marker_trans, path, trans, rgbFace=None): 462 | super().draw_markers(gc, marker_path, marker_trans, path, trans, rgbFace) 463 | 464 | def draw_image(self, gc, x, y, im, transform=None): 465 | im = np.flipud(im) 466 | h, w, d = im.shape 467 | y = self.ctx.height - y - h 468 | im = np.ravel(np.uint8(np.reshape(im, (h * w * d, -1)))).tobytes() 469 | pixels_proxy = create_proxy(im) 470 | pixels_buf = pixels_proxy.getBuffer("u8clamped") 471 | img_data = ImageData.new(pixels_buf.data, w, h) 472 | self.ctx.save() 473 | in_memory_canvas = document.createElement("canvas") 474 | in_memory_canvas.width = w 475 | in_memory_canvas.height = h 476 | in_memory_canvas_context = in_memory_canvas.getContext("2d") 477 | in_memory_canvas_context.putImageData(img_data, 0, 0) 478 | self.ctx.drawImage(in_memory_canvas, x, y, w, h) 479 | self.ctx.restore() 480 | pixels_proxy.destroy() 481 | pixels_buf.release() 482 | 483 | def _get_font_helper(self, prop): 484 | """Cached font lookup 485 | 486 | We wrap this in an lru-cache in the constructor. 487 | """ 488 | fname = findfont(prop) 489 | font = FT2Font(str(fname)) 490 | font_file_name = fname.rpartition("/")[-1] 491 | return (font, font_file_name) 492 | 493 | def _get_font(self, prop): 494 | result = self._get_font_helper(prop) 495 | font = result[0] 496 | font.clear() 497 | font.set_size(prop.get_size_in_points(), self.dpi) 498 | return result 499 | 500 | def get_text_width_height_descent(self, s, prop, ismath): 501 | w: float 502 | h: float 503 | d: float 504 | if ismath: 505 | # Use the path parser to get exact metrics 506 | width, height, depth, _, _ = self.mathtext_parser.parse( 507 | s, dpi=72, prop=prop 508 | ) 509 | return width, height, depth 510 | else: 511 | font, _ = self._get_font(prop) 512 | font.set_text(s, 0.0, flags=LOAD_NO_HINTING) 513 | w, h = font.get_width_height() 514 | w /= 64.0 515 | h /= 64.0 516 | d = font.get_descent() / 64.0 517 | return w, h, d 518 | 519 | def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): 520 | if ismath: 521 | self._draw_math_text(gc, x, y, s, prop, angle) 522 | return 523 | 524 | angle = math.radians(angle) 525 | width, height, descent = self.get_text_width_height_descent(s, prop, ismath) 526 | x -= math.sin(angle) * descent 527 | y -= math.cos(angle) * descent - self.ctx.height 528 | font_size = self.points_to_pixels(prop.get_size_in_points()) 529 | 530 | _, font_file_name = self._get_font(prop) 531 | 532 | font_face_arguments = ( 533 | prop.get_name(), 534 | f"url({_base_fonts_url + font_file_name})", 535 | ) 536 | 537 | # The following snippet loads a font into the browser's 538 | # environment if it wasn't loaded before. This check is necessary 539 | # to help us avoid loading the same font multiple times. Further, 540 | # it helps us to avoid the infinite loop of 541 | # load font --> redraw --> load font --> redraw --> .... 542 | 543 | if font_face_arguments not in _font_set: 544 | _font_set.add(font_face_arguments) 545 | f = FontFace.new(*font_face_arguments) 546 | 547 | font_url = font_face_arguments[1] 548 | self.fonts_loading[font_url] = f 549 | f.load().add_done_callback( 550 | lambda result: self.load_font_into_web(result, font_url) 551 | ) 552 | 553 | font_property_string = "{} {} {:.3g}px {}, {}".format( 554 | prop.get_style(), 555 | prop.get_weight(), 556 | font_size, 557 | prop.get_name(), 558 | prop.get_family()[0], 559 | ) 560 | if angle != 0: 561 | self.ctx.save() 562 | self.ctx.translate(x, y) 563 | self.ctx.rotate(-angle) 564 | self.ctx.translate(-x, -y) 565 | self.ctx.font = font_property_string 566 | self.ctx.fillStyle = self._matplotlib_color_to_CSS( 567 | gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha() 568 | ) 569 | self.ctx.fillText(s, x, y) 570 | self.ctx.fillStyle = "#000000" 571 | if angle != 0: 572 | self.ctx.restore() 573 | 574 | def load_font_into_web(self, loaded_face, font_url): 575 | fontface = loaded_face.result() 576 | document.fonts.add(fontface) 577 | self.fonts_loading.pop(font_url, None) 578 | 579 | # Redraw figure after font has loaded 580 | self.fig.draw() 581 | return fontface 582 | 583 | 584 | class FigureManagerHTMLCanvas(FigureManagerBase): 585 | def __init__(self, canvas, num): 586 | super().__init__(canvas, num) 587 | self.set_window_title("Figure %d" % num) 588 | self.toolbar = NavigationToolbar2HTMLCanvas(canvas) 589 | 590 | def show(self, *args, **kwargs): 591 | self.canvas.show(*args, **kwargs) 592 | 593 | def destroy(self, *args, **kwargs): 594 | self.canvas.destroy(*args, **kwargs) 595 | 596 | def resize(self, w, h): 597 | pass 598 | 599 | def set_window_title(self, title): 600 | self.canvas.set_window_title(title) 601 | 602 | 603 | @_Backend.export 604 | class _BackendHTMLCanvas(_Backend): 605 | # FigureCanvas = FigureCanvasHTMLCanvas 606 | # FigureManager = FigureManagerHTMLCanvas 607 | # Note: with release 0.2.3, we've redirected the HTMLCanvas backend to use the WASM backend 608 | # for now, as the changes to the HTMLCanvas backend are not yet fully functional. 609 | # This will be updated in a future release. 610 | FigureCanvas = FigureCanvasAggWasm 611 | FigureManager = FigureManagerAggWasm 612 | 613 | @staticmethod 614 | def show(*args, **kwargs): 615 | from matplotlib import pyplot as plt 616 | 617 | plt.gcf().canvas.show(*args, **kwargs) 618 | 619 | @staticmethod 620 | def destroy(*args, **kwargs): 621 | from matplotlib import pyplot as plt 622 | 623 | plt.gcf().canvas.destroy(*args, **kwargs) 624 | -------------------------------------------------------------------------------- /matplotlib_pyodide/wasm_backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | A matplotlib backend that renders to an HTML5 canvas in the same thread. 3 | 4 | The Agg backend is used for the actual rendering underneath, and renders the 5 | buffer to the HTML5 canvas. This happens with only a single copy of the data 6 | into the Canvas -- passing the data from Python to JavaScript requires no 7 | copies. 8 | 9 | See matplotlib.backend_bases for documentation for most of the methods, since 10 | this primarily is just overriding methods in the base class. 11 | """ 12 | 13 | # TODO: Figure resizing support 14 | 15 | import base64 16 | import io 17 | 18 | from js import ImageData, document 19 | from matplotlib import interactive 20 | from matplotlib.backend_bases import FigureManagerBase, _Backend 21 | from matplotlib.backends import backend_agg 22 | 23 | from matplotlib_pyodide.browser_backend import FigureCanvasWasm, NavigationToolbar2Wasm 24 | 25 | interactive(True) 26 | 27 | 28 | class FigureCanvasAggWasm(backend_agg.FigureCanvasAgg, FigureCanvasWasm): 29 | def __init__(self, *args, **kwargs): 30 | backend_agg.FigureCanvasAgg.__init__(self, *args, **kwargs) 31 | FigureCanvasWasm.__init__(self, *args, **kwargs) 32 | 33 | def draw(self): 34 | from pyodide.ffi import create_proxy 35 | 36 | # Render the figure using Agg 37 | self._idle_scheduled = True 38 | orig_dpi = self.figure.dpi 39 | if self._ratio != 1: 40 | self.figure.dpi *= self._ratio 41 | pixels_proxy = None 42 | pixels_buf = None 43 | try: 44 | super().draw() 45 | # Copy the image buffer to the canvas 46 | width, height = self.get_width_height() 47 | canvas = self.get_element("canvas") 48 | if canvas is None: 49 | return 50 | pixels = self.buffer_rgba().tobytes() 51 | pixels_proxy = create_proxy(pixels) 52 | pixels_buf = pixels_proxy.getBuffer("u8clamped") 53 | image_data = ImageData.new(pixels_buf.data, width, height) 54 | ctx = canvas.getContext("2d") 55 | ctx.putImageData(image_data, 0, 0) 56 | finally: 57 | self.figure.dpi = orig_dpi 58 | self._idle_scheduled = False 59 | if pixels_proxy: 60 | pixels_proxy.destroy() 61 | if pixels_buf: 62 | pixels_buf.release() 63 | 64 | 65 | class NavigationToolbar2AggWasm(NavigationToolbar2Wasm): 66 | def download(self, format, mimetype): 67 | # Creates a temporary `a` element with a URL containing the image 68 | # content, and then virtually clicks it. Kind of magical, but it 69 | # works... 70 | element = document.createElement("a") 71 | data = io.BytesIO() 72 | try: 73 | self.canvas.figure.savefig(data, format=format) 74 | except Exception: 75 | raise 76 | element.setAttribute( 77 | "href", 78 | "data:{};base64,{}".format( 79 | mimetype, base64.b64encode(data.getvalue()).decode("ascii") 80 | ), 81 | ) 82 | element.setAttribute("download", f"plot.{format}") 83 | element.style.display = "none" 84 | document.body.appendChild(element) 85 | element.click() 86 | document.body.removeChild(element) 87 | 88 | 89 | class FigureManagerAggWasm(FigureManagerBase): 90 | def __init__(self, canvas, num): 91 | FigureManagerBase.__init__(self, canvas, num) 92 | self.set_window_title("Figure %d" % num) 93 | self.toolbar = NavigationToolbar2AggWasm(canvas) 94 | 95 | def show(self, *args, **kwargs): 96 | self.canvas.show(*args, **kwargs) 97 | 98 | def destroy(self, *args, **kwargs): 99 | self.canvas.destroy(*args, **kwargs) 100 | 101 | def resize(self, w, h): 102 | pass 103 | 104 | def set_window_title(self, title): 105 | self.canvas.set_window_title(title) 106 | 107 | 108 | @_Backend.export 109 | class _BackendWasmCoreAgg(_Backend): 110 | FigureCanvas = FigureCanvasAggWasm 111 | FigureManager = FigureManagerAggWasm 112 | 113 | @staticmethod 114 | def show(*args, **kwargs): 115 | from matplotlib import pyplot as plt 116 | 117 | plt.gcf().canvas.show(*args, **kwargs) 118 | 119 | @staticmethod 120 | def destroy(*args, **kwargs): 121 | from matplotlib import pyplot as plt 122 | 123 | plt.gcf().canvas.destroy(*args, **kwargs) 124 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "matplotlib-pyodide" 3 | authors = [ 4 | { name="Pyodide developers"}, 5 | ] 6 | description = "HTML5 backends for Matplotlib compatible with Pyodide" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.12" 10 | dynamic = ["version"] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 14 | "Operating System :: OS Independent", 15 | ] 16 | 17 | [build-system] 18 | requires = ["setuptools>=71", "setuptools_scm[toml]>=6.2"] 19 | 20 | build-backend = "setuptools.build_meta" 21 | 22 | [project.urls] 23 | "Homepage" = "https://github.com/pyodide/matplotlib-pyodide" 24 | "Bug Tracker" = "https://github.com/pyodide/matplotlib-pyodide/issues" 25 | 26 | [project.optional-dependencies] 27 | test = [ 28 | "pytest-pyodide==0.56.2", 29 | "pytest-cov", 30 | "build>=1.0", 31 | ] 32 | 33 | 34 | 35 | # Evable versioning via setuptools_scm 36 | [tool.setuptools_scm] 37 | 38 | [tool.pycln] 39 | all = true 40 | 41 | [tool.isort] 42 | profile = "black" 43 | known_first_party = [ 44 | "pyodide", 45 | "pyodide_js", 46 | "micropip", 47 | ] 48 | 49 | [tool.mypy] 50 | python_version = "3.12" 51 | show_error_codes = true 52 | warn_unreachable = true 53 | ignore_missing_imports = true 54 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from pathlib import Path 3 | 4 | import pytest 5 | from pytest_pyodide import spawn_web_server 6 | 7 | DECORATORS = [ 8 | pytest.mark.xfail_browsers(node="No supported matplotlib backends on node"), 9 | pytest.mark.skip_refcount_check, 10 | pytest.mark.skip_pyproxy_check, 11 | pytest.mark.driver_timeout(60), 12 | ] 13 | 14 | 15 | def matplotlib_test_decorator(f): 16 | return reduce(lambda x, g: g(x), DECORATORS, f) 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def wheel_path(tmp_path_factory): 21 | # Build a micropip wheel for testing 22 | import build 23 | from build.env import DefaultIsolatedEnv 24 | 25 | output_dir = tmp_path_factory.mktemp("wheel") 26 | 27 | with DefaultIsolatedEnv() as env: 28 | builder = build.ProjectBuilder( 29 | source_dir=Path(__file__).parent.parent, 30 | python_executable=env.python_executable, 31 | ) 32 | env.install(builder.build_system_requires) 33 | builder.build("wheel", output_directory=output_dir) 34 | 35 | yield output_dir 36 | 37 | 38 | @pytest.fixture 39 | def selenium_standalone_matplotlib(selenium_standalone, wheel_path): 40 | wheel_dir = Path(wheel_path) 41 | wheel_files = list(wheel_dir.glob("*.whl")) 42 | 43 | if not wheel_files: 44 | pytest.exit("No wheel files found in wheel/ directory") 45 | 46 | wheel_file = wheel_files[0] 47 | with spawn_web_server(wheel_dir) as server: 48 | server_hostname, server_port, _ = server 49 | base_url = f"http://{server_hostname}:{server_port}/" 50 | selenium_standalone.run_js( 51 | f""" 52 | await pyodide.loadPackage({base_url + wheel_file.name!r}); 53 | await pyodide.loadPackage(["matplotlib"]); 54 | pyodide.runPython("import matplotlib"); 55 | """ 56 | ) 57 | 58 | yield selenium_standalone 59 | -------------------------------------------------------------------------------- /tests/test_data/canvas-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-custom-font-text-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-custom-font-text-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-custom-font-text-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-custom-font-text-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-custom-font-text-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-custom-font-text-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-affine-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-affine-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-affine-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-affine-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-affine-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-affine-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-image-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-image-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-math-text-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-math-text-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-math-text-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-math-text-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-math-text-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-math-text-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-polar-zoom-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-polar-zoom-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-polar-zoom-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-polar-zoom-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-polar-zoom-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-polar-zoom-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-text-rotated-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-text-rotated-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-text-rotated-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-text-rotated-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-text-rotated-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-text-rotated-safari.png -------------------------------------------------------------------------------- /tests/test_data/canvas-transparency-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-transparency-chrome.png -------------------------------------------------------------------------------- /tests/test_data/canvas-transparency-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-transparency-firefox.png -------------------------------------------------------------------------------- /tests/test_data/canvas-transparency-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyodide/matplotlib-pyodide/4a1553357b8f4d7d2005510fb309d8d1fac0ba03/tests/test_data/canvas-transparency-safari.png -------------------------------------------------------------------------------- /tests/test_html5_canvas_backend.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pathlib 3 | 4 | from conftest import matplotlib_test_decorator 5 | from pytest_pyodide import run_in_pyodide 6 | 7 | REFERENCE_IMAGES_PATH = pathlib.Path(__file__).parent / "test_data" 8 | 9 | 10 | def save_canvas_data(selenium, output_path): 11 | canvas_data = selenium.run( 12 | """ 13 | import base64 14 | canvas = plt.gcf().canvas.get_element("canvas") 15 | canvas_data = canvas.toDataURL("image/png")[21:] 16 | canvas_data 17 | """ 18 | ) 19 | 20 | canvas_png = base64.b64decode(canvas_data) 21 | output_path.write_bytes(canvas_png) 22 | 23 | 24 | @run_in_pyodide(packages=["matplotlib"]) 25 | def patch_font_loading_and_dpi(selenium, handle, target_font=""): 26 | """Monkey-patches font loading and dpi to allow testing""" 27 | from matplotlib_pyodide.html5_canvas_backend import ( 28 | FigureCanvasHTMLCanvas, 29 | RendererHTMLCanvas, 30 | ) 31 | 32 | FigureCanvasHTMLCanvas.get_dpi_ratio = lambda self, context: 2.0 33 | load_font_into_web = RendererHTMLCanvas.load_font_into_web 34 | 35 | def load_font_into_web_wrapper( 36 | self, loaded_font, font_url, orig_function=load_font_into_web 37 | ): 38 | fontface = orig_function(self, loaded_font, font_url) 39 | 40 | if not target_font or target_font == fontface.family: 41 | try: 42 | handle.font_loaded = True 43 | except Exception as e: 44 | raise ValueError("unable to resolve") from e 45 | 46 | RendererHTMLCanvas.load_font_into_web = load_font_into_web_wrapper 47 | 48 | 49 | def compare_func_handle(selenium): 50 | @run_in_pyodide(packages=["matplotlib"]) 51 | def prepare(selenium): 52 | from pytest_pyodide.decorator import PyodideHandle 53 | 54 | class Handle: 55 | def __init__(self): 56 | self.font_loaded = False 57 | 58 | async def compare(self, ref): 59 | import asyncio 60 | import io 61 | 62 | import matplotlib.pyplot as plt 63 | import numpy as np 64 | from PIL import Image 65 | 66 | while not self.font_loaded: # wait until font is loading 67 | await asyncio.sleep(0.2) 68 | 69 | canvas_data = plt.gcf().canvas.get_pixel_data() 70 | ref_data = np.asarray(Image.open(io.BytesIO(ref))) 71 | 72 | deviation = np.mean(np.abs(canvas_data - ref_data)) 73 | assert float(deviation) == 0.0 74 | 75 | return PyodideHandle(Handle()) 76 | 77 | handle = prepare(selenium) 78 | return handle 79 | 80 | 81 | @matplotlib_test_decorator 82 | def test_rendering(selenium_standalone_matplotlib): 83 | selenium = selenium_standalone_matplotlib 84 | 85 | @run_in_pyodide(packages=["matplotlib"]) 86 | def run(selenium, handle, ref): 87 | import matplotlib 88 | 89 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 90 | import matplotlib.pyplot as plt 91 | import numpy as np 92 | 93 | t = np.arange(0.0, 2.0, 0.01) 94 | s = 1 + np.sin(2 * np.pi * t) 95 | plt.figure() 96 | plt.plot(t, s, linewidth=1.0, marker=11) 97 | plt.plot(t, t) 98 | plt.grid(True) 99 | plt.show() 100 | 101 | handle.compare(ref) 102 | 103 | ref = (REFERENCE_IMAGES_PATH / f"canvas-{selenium.browser}.png").read_bytes() 104 | handle = compare_func_handle(selenium) 105 | patch_font_loading_and_dpi(selenium, handle) 106 | run(selenium, handle, ref) 107 | 108 | 109 | @matplotlib_test_decorator 110 | def test_draw_image(selenium_standalone_matplotlib): 111 | selenium = selenium_standalone_matplotlib 112 | 113 | @run_in_pyodide(packages=["matplotlib"]) 114 | def run(selenium, handle, ref): 115 | import matplotlib 116 | 117 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 118 | import matplotlib.cm as cm 119 | import matplotlib.pyplot as plt 120 | import numpy as np 121 | 122 | delta = 0.025 123 | x = y = np.arange(-3.0, 3.0, delta) 124 | X, Y = np.meshgrid(x, y) 125 | Z1 = np.exp(-(X**2) - Y**2) 126 | Z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) 127 | Z = (Z1 - Z2) * 2 128 | plt.figure() 129 | plt.imshow( 130 | Z, 131 | interpolation="bilinear", 132 | cmap=cm.RdYlGn, 133 | origin="lower", 134 | extent=[-3, 3, -3, 3], 135 | vmax=abs(Z).max(), 136 | vmin=-abs(Z).max(), 137 | ) 138 | plt.show() 139 | 140 | handle.compare(ref) 141 | 142 | ref = (REFERENCE_IMAGES_PATH / f"canvas-image-{selenium.browser}.png").read_bytes() 143 | handle = compare_func_handle(selenium) 144 | patch_font_loading_and_dpi(selenium, handle) 145 | run(selenium, handle, ref) 146 | 147 | 148 | @matplotlib_test_decorator 149 | def test_draw_image_affine_transform(selenium_standalone_matplotlib): 150 | selenium = selenium_standalone_matplotlib 151 | 152 | @run_in_pyodide(packages=["matplotlib"]) 153 | def run(selenium, handle, ref): 154 | import matplotlib 155 | 156 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 157 | import matplotlib.pyplot as plt 158 | import matplotlib.transforms as mtransforms 159 | import numpy as np 160 | 161 | def get_image(): 162 | delta = 0.25 163 | x = y = np.arange(-3.0, 3.0, delta) 164 | X, Y = np.meshgrid(x, y) 165 | Z1 = np.exp(-(X**2) - Y**2) 166 | Z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) 167 | Z = Z1 - Z2 168 | return Z 169 | 170 | def do_plot(ax, Z, transform): 171 | im = ax.imshow( 172 | Z, 173 | interpolation="none", 174 | origin="lower", 175 | extent=[-2, 4, -3, 2], 176 | clip_on=True, 177 | ) 178 | 179 | trans_data = transform + ax.transData 180 | im.set_transform(trans_data) 181 | 182 | # display intended extent of the image 183 | x1, x2, y1, y2 = im.get_extent() 184 | ax.plot( 185 | [x1, x2, x2, x1, x1], [y1, y1, y2, y2, y1], "y--", transform=trans_data 186 | ) 187 | ax.set_xlim(-5, 5) 188 | ax.set_ylim(-4, 4) 189 | 190 | # prepare image and figure 191 | fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) 192 | Z = get_image() 193 | 194 | # image rotation 195 | do_plot(ax1, Z, mtransforms.Affine2D().rotate_deg(30)) 196 | 197 | # image skew 198 | do_plot(ax2, Z, mtransforms.Affine2D().skew_deg(30, 15)) 199 | 200 | # scale and reflection 201 | do_plot(ax3, Z, mtransforms.Affine2D().scale(-1, 0.5)) 202 | 203 | # everything and a translation 204 | do_plot( 205 | ax4, 206 | Z, 207 | mtransforms.Affine2D() 208 | .rotate_deg(30) 209 | .skew_deg(30, 15) 210 | .scale(-1, 0.5) 211 | .translate(0.5, -1), 212 | ) 213 | 214 | plt.show() 215 | 216 | handle.compare(ref) 217 | 218 | ref = ( 219 | REFERENCE_IMAGES_PATH / f"canvas-image-affine-{selenium.browser}.png" 220 | ).read_bytes() 221 | handle = compare_func_handle(selenium) 222 | patch_font_loading_and_dpi(selenium, handle) 223 | run(selenium, handle, ref) 224 | 225 | 226 | @matplotlib_test_decorator 227 | def test_draw_text_rotated(selenium_standalone_matplotlib): 228 | selenium = selenium_standalone_matplotlib 229 | 230 | @run_in_pyodide(packages=["matplotlib"]) 231 | def run(selenium, handle, ref): 232 | import matplotlib 233 | 234 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 235 | import datetime 236 | 237 | import matplotlib.pyplot as plt 238 | import numpy as np 239 | from matplotlib.dates import ( 240 | YEARLY, 241 | DateFormatter, 242 | RRuleLocator, 243 | drange, 244 | rrulewrapper, 245 | ) 246 | 247 | # tick every 5th easter 248 | np.random.seed(42) 249 | rule = rrulewrapper(YEARLY, byeaster=1, interval=5) 250 | loc = RRuleLocator(rule) 251 | formatter = DateFormatter("%m/%d/%y") 252 | date1 = datetime.date(1952, 1, 1) 253 | date2 = datetime.date(2004, 4, 12) 254 | delta = datetime.timedelta(days=100) 255 | 256 | dates = drange(date1, date2, delta) 257 | s = np.random.rand(len(dates)) # make up some random y values 258 | 259 | fig, ax = plt.subplots() 260 | plt.plot_date(dates, s) 261 | ax.xaxis.set_major_locator(loc) 262 | ax.xaxis.set_major_formatter(formatter) 263 | labels = ax.get_xticklabels() 264 | plt.setp(labels, rotation=30, fontsize=10) 265 | 266 | plt.show() 267 | 268 | handle.compare(ref) 269 | 270 | ref = ( 271 | REFERENCE_IMAGES_PATH / f"canvas-text-rotated-{selenium.browser}.png" 272 | ).read_bytes() 273 | handle = compare_func_handle(selenium) 274 | patch_font_loading_and_dpi(selenium, handle) 275 | run(selenium, handle, ref) 276 | 277 | 278 | @matplotlib_test_decorator 279 | def test_draw_math_text(selenium_standalone_matplotlib): 280 | selenium = selenium_standalone_matplotlib 281 | 282 | @run_in_pyodide(packages=["matplotlib"]) 283 | def run(selenium, handle, ref): 284 | import matplotlib 285 | 286 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 287 | 288 | import matplotlib.pyplot as plt 289 | 290 | # Selection of features following 291 | # "Writing mathematical expressions" tutorial 292 | mathtext_titles = { 293 | 0: "Header demo", 294 | 1: "Subscripts and superscripts", 295 | 2: "Fractions, binomials and stacked numbers", 296 | 3: "Radicals", 297 | 4: "Fonts", 298 | 5: "Accents", 299 | 6: "Greek, Hebrew", 300 | 7: "Delimiters, functions and Symbols", 301 | } 302 | n_lines = len(mathtext_titles) 303 | 304 | # Randomly picked examples 305 | mathext_demos = { 306 | 0: r"$W^{3\beta}_{\delta_1 \rho_1 \sigma_2} = " 307 | r"U^{3\beta}_{\delta_1 \rho_1} + \frac{1}{8 \pi 2} " 308 | r"\int^{\alpha_2}_{\alpha_2} d \alpha^\prime_2 \left[\frac{ " 309 | r"U^{2\beta}_{\delta_1 \rho_1} - \alpha^\prime_2U^{1\beta}_" 310 | r"{\rho_1 \sigma_2} }{U^{0\beta}_{\rho_1 \sigma_2}}\right]$", 311 | 1: r"$\alpha_i > \beta_i,\ " 312 | r"\alpha_{i+1}^j = {\rm sin}(2\pi f_j t_i) e^{-5 t_i/\tau},\ " 313 | r"\ldots$", 314 | 2: r"$\frac{3}{4},\ \binom{3}{4},\ \genfrac{}{}{0}{}{3}{4},\ " 315 | r"\left(\frac{5 - \frac{1}{x}}{4}\right),\ \ldots$", 316 | 3: r"$\sqrt{2},\ \sqrt[3]{x},\ \ldots$", 317 | 4: r"$\mathrm{Roman}\ , \ \mathit{Italic}\ , \ \mathtt{Typewriter} \ " 318 | r"\mathrm{or}\ \mathcal{CALLIGRAPHY}$", 319 | 5: r"$\acute a,\ \bar a,\ \breve a,\ \dot a,\ \ddot a, \ \grave a, \ " 320 | r"\hat a,\ \tilde a,\ \vec a,\ \widehat{xyz},\ \widetilde{xyz},\ " 321 | r"\ldots$", 322 | 6: r"$\alpha,\ \beta,\ \chi,\ \delta,\ \lambda,\ \mu,\ " 323 | r"\Delta,\ \Gamma,\ \Omega,\ \Phi,\ \Pi,\ \Upsilon,\ \nabla,\ " 324 | r"\aleph,\ \beth,\ \daleth,\ \gimel,\ \ldots$", 325 | 7: r"$\coprod,\ \int,\ \oint,\ \prod,\ \sum,\ " 326 | r"\log,\ \sin,\ \approx,\ \oplus,\ \star,\ \varpropto,\ " 327 | r"\infty,\ \partial,\ \Re,\ \leftrightsquigarrow, \ \ldots$", 328 | } 329 | 330 | def doall(): 331 | # Colors used in mpl online documentation. 332 | mpl_blue_rvb = (191.0 / 255.0, 209.0 / 256.0, 212.0 / 255.0) 333 | mpl_orange_rvb = (202.0 / 255.0, 121.0 / 256.0, 0.0 / 255.0) 334 | mpl_grey_rvb = (51.0 / 255.0, 51.0 / 255.0, 51.0 / 255.0) 335 | 336 | # Creating figure and axis. 337 | plt.figure(figsize=(6, 7)) 338 | plt.axes([0.01, 0.01, 0.98, 0.90], facecolor="white", frameon=True) 339 | plt.gca().set_xlim(0.0, 1.0) 340 | plt.gca().set_ylim(0.0, 1.0) 341 | plt.gca().set_title( 342 | "Matplotlib's math rendering engine", 343 | color=mpl_grey_rvb, 344 | fontsize=14, 345 | weight="bold", 346 | ) 347 | plt.gca().set_xticklabels("", visible=False) 348 | plt.gca().set_yticklabels("", visible=False) 349 | 350 | # Gap between lines in axes coords 351 | line_axesfrac = 1.0 / (n_lines) 352 | 353 | # Plotting header demonstration formula 354 | full_demo = mathext_demos[0] 355 | plt.annotate( 356 | full_demo, 357 | xy=(0.5, 1.0 - 0.59 * line_axesfrac), 358 | color=mpl_orange_rvb, 359 | ha="center", 360 | fontsize=20, 361 | ) 362 | 363 | # Plotting features demonstration formulae 364 | for i_line in range(1, n_lines): 365 | baseline = 1 - (i_line) * line_axesfrac 366 | baseline_next = baseline - line_axesfrac 367 | title = mathtext_titles[i_line] + ":" 368 | fill_color = ["white", mpl_blue_rvb][i_line % 2] 369 | plt.fill_between( 370 | [0.0, 1.0], 371 | [baseline, baseline], 372 | [baseline_next, baseline_next], 373 | color=fill_color, 374 | alpha=0.5, 375 | ) 376 | plt.annotate( 377 | title, 378 | xy=(0.07, baseline - 0.3 * line_axesfrac), 379 | color=mpl_grey_rvb, 380 | weight="bold", 381 | ) 382 | demo = mathext_demos[i_line] 383 | plt.annotate( 384 | demo, 385 | xy=(0.05, baseline - 0.75 * line_axesfrac), 386 | color=mpl_grey_rvb, 387 | fontsize=16, 388 | ) 389 | 390 | for i in range(n_lines): 391 | s = mathext_demos[i] 392 | print(i, s) 393 | plt.show() 394 | 395 | doall() 396 | 397 | handle.compare(ref) 398 | 399 | ref = ( 400 | REFERENCE_IMAGES_PATH / f"canvas-math-text-{selenium.browser}.png" 401 | ).read_bytes() 402 | handle = compare_func_handle(selenium) 403 | patch_font_loading_and_dpi(selenium, handle) 404 | run(selenium, handle, ref) 405 | 406 | 407 | @matplotlib_test_decorator 408 | def test_custom_font_text(selenium_standalone_matplotlib): 409 | selenium = selenium_standalone_matplotlib 410 | 411 | @run_in_pyodide(packages=["matplotlib"]) 412 | def run(selenium, handle, ref): 413 | import matplotlib 414 | 415 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 416 | import matplotlib.pyplot as plt 417 | import numpy as np 418 | 419 | f = {"fontname": "cmsy10"} 420 | 421 | t = np.arange(0.0, 2.0, 0.01) 422 | s = 1 + np.sin(2 * np.pi * t) 423 | plt.figure() 424 | plt.title("A simple Sine Curve", **f) 425 | plt.plot(t, s, linewidth=1.0, marker=11) 426 | plt.plot(t, t) 427 | plt.grid(True) 428 | plt.show() 429 | 430 | handle.compare(ref) 431 | 432 | ref = ( 433 | REFERENCE_IMAGES_PATH / f"canvas-custom-font-text-{selenium.browser}.png" 434 | ).read_bytes() 435 | handle = compare_func_handle(selenium) 436 | patch_font_loading_and_dpi(selenium, handle) 437 | run(selenium, handle, ref) 438 | 439 | 440 | @matplotlib_test_decorator 441 | def test_zoom_on_polar_plot(selenium_standalone_matplotlib): 442 | selenium = selenium_standalone_matplotlib 443 | 444 | @run_in_pyodide(packages=["matplotlib"]) 445 | def run(selenium, handle, ref): 446 | import matplotlib 447 | 448 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 449 | import matplotlib.pyplot as plt 450 | import numpy as np 451 | 452 | np.random.seed(42) 453 | 454 | # Compute pie slices 455 | N = 20 456 | theta = np.linspace(0.0, 2 * np.pi, N, endpoint=False) 457 | radii = 10 * np.random.rand(N) 458 | width = np.pi / 4 * np.random.rand(N) 459 | 460 | ax = plt.subplot(111, projection="polar") 461 | bars = ax.bar(theta, radii, width=width, bottom=0.0) 462 | 463 | # Use custom colors and opacity 464 | for r, bar in zip(radii, bars, strict=True): 465 | bar.set_facecolor(plt.cm.viridis(r / 10.0)) 466 | bar.set_alpha(0.5) 467 | 468 | ax.set_rlim([0, 5]) 469 | plt.show() 470 | 471 | handle.compare(ref) 472 | 473 | ref = ( 474 | REFERENCE_IMAGES_PATH / f"canvas-polar-zoom-{selenium.browser}.png" 475 | ).read_bytes() 476 | handle = compare_func_handle(selenium) 477 | patch_font_loading_and_dpi(selenium, handle) 478 | run(selenium, handle, ref) 479 | 480 | 481 | @matplotlib_test_decorator 482 | def test_transparency(selenium_standalone_matplotlib): 483 | selenium = selenium_standalone_matplotlib 484 | 485 | @run_in_pyodide(packages=["matplotlib"]) 486 | def run(selenium, handle, ref): 487 | import matplotlib 488 | 489 | matplotlib.use("module://matplotlib_pyodide.html5_canvas_backend") 490 | import numpy as np 491 | 492 | np.random.seed(19680801) 493 | import matplotlib.pyplot as plt 494 | 495 | fig, ax = plt.subplots() 496 | for color in ["tab:blue", "tab:orange", "tab:green"]: 497 | n = 100 498 | x, y = np.random.rand(2, n) 499 | scale = 200.0 * np.random.rand(n) 500 | ax.scatter( 501 | x, y, c=color, s=scale, label=color, alpha=0.3, edgecolors="none" 502 | ) 503 | 504 | ax.legend() 505 | ax.grid(True) 506 | 507 | plt.show() 508 | 509 | handle.compare(ref) 510 | 511 | ref = ( 512 | REFERENCE_IMAGES_PATH / f"canvas-transparency-{selenium.browser}.png" 513 | ).read_bytes() 514 | handle = compare_func_handle(selenium) 515 | patch_font_loading_and_dpi(selenium, handle) 516 | run(selenium, handle, ref) 517 | -------------------------------------------------------------------------------- /tests/test_matplotlib.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from conftest import matplotlib_test_decorator 3 | from pytest_pyodide import run_in_pyodide 4 | 5 | 6 | @matplotlib_test_decorator 7 | @run_in_pyodide(packages=["matplotlib"]) 8 | def test_plot(selenium_standalone_matplotlib): 9 | from matplotlib import pyplot as plt 10 | 11 | plt.figure() 12 | plt.plot([1, 2, 3]) 13 | plt.show() 14 | 15 | 16 | @pytest.mark.skip(reason="wrong version of matplotlib_pyodide in tests") 17 | @matplotlib_test_decorator 18 | @run_in_pyodide(packages=["matplotlib"]) 19 | def test_plot_with_pause(selenium_standalone_matplotlib): 20 | from matplotlib import pyplot as plt 21 | 22 | plt.figure() 23 | plt.plot([1, 2, 3]) 24 | plt.pause(0.001) 25 | plt.show() 26 | 27 | 28 | @matplotlib_test_decorator 29 | @run_in_pyodide(packages=["matplotlib"]) 30 | def test_svg(selenium_standalone_matplotlib): 31 | import io 32 | 33 | from matplotlib import pyplot as plt 34 | 35 | plt.figure() 36 | plt.plot([1, 2, 3]) 37 | fd = io.BytesIO() 38 | plt.savefig(fd, format="svg") 39 | 40 | content = fd.getvalue().decode("utf8") 41 | assert len(content) == 14998 42 | assert content.startswith("