diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/_animation_data.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/_animation_data.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d61f5bdeac3432d496100d71f7522b2754aa7d31 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/_animation_data.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/_cm_multivar.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/_cm_multivar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46e1ca9637d8e9894873e3169de39239acc6aa36 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/_cm_multivar.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/_pylab_helpers.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/_pylab_helpers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0af25ae725df8a51b1b5963c188520166a5e9e01 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/_pylab_helpers.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/backend_managers.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/backend_managers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da7173b86c58e60bb813d88e86f4e2c9dc826922 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/backend_managers.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/colorbar.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/colorbar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c31acc179c637628bfa767694b16de0cc0baefc Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/colorbar.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/colorizer.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/colorizer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e23d8e097e7c9b93b5e81651098cb35258cbc3da Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/colorizer.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/contour.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/contour.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13963b1ce60eb1bd2300f4250477d1fe8d9dcd81 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/contour.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/hatch.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/hatch.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69d406a1ce728df8c33e21db863da92d9683a0e4 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/hatch.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/lines.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/lines.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b71b7e1f6ae7d8f73b8f08c8b82990995b7211c1 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/lines.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/quiver.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/quiver.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b89423e5f41781a49f21b6ff2f0285dd9905523d Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/quiver.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/rcsetup.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/rcsetup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..92173b2911963d8503fc207f26995ed2dd6e6860 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/rcsetup.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/__pycache__/streamplot.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/__pycache__/streamplot.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67efce98c7737ea6fe4e32d5f373e58a2ab3566f Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/__pycache__/streamplot.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..68f07f75720f3958e5c0325b6e4e701ad67d4165 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_gtk.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_gtk.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a23bf04e4b0210560220ea6dfc8186f45eeab839 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_gtk.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_pdf_ps.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_pdf_ps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0783294cfae4056d85b4b449b41ebbb5f6d7b453 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_pdf_ps.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_tk.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_tk.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f68f7abb34ecee84f5dc1d36074289fc3381f07 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/_backend_tk.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_agg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_agg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..654d551b67754bbbe0e3c25672ccc420914042b0 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_agg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_cairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_cairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e651444c341e549d7f988b4d68be29f35ed4cb84 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_cairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8440c929b75052f196f672b8ae98d510c51e37c9 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3agg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3agg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7cae4b379c8d96dfbb5ab466546e6af0cf4f952 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3agg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3cairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3cairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..375dc63713ab63b375f3d1541c8462c08221d436 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk3cairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44e997846cec04b7a8109a7310afb9d3525eaa8c Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4agg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4agg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7dcadf8e64a085f380b090b86fa9ddfd018ee88c Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4agg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4cairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4cairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43fac4fe7001f3f595a80689f0421442ffc6a20a Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_gtk4cairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_macosx.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_macosx.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ae7d05d32a612acb39971f1681059671e8480ea Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_macosx.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_mixed.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_mixed.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53b2b68f76f6f4f7df180f748a25c8fef0096511 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_mixed.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_nbagg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_nbagg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f31d7d005ab5930ab679befa1ce862b0ef1c026 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_nbagg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_pgf.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_pgf.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e02b28be74e11f986a1ca43ebbf558bb7851563 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_pgf.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_ps.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_ps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bb060434a4663185468e07fccc1ef26ec238e80 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_ps.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51d07eeafdd3f5e66deff231e53c434a1f3ca87a Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6e1f595d2c1f2393f0bdec5ce7a186200ee8121 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5agg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5agg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61dc0ab513d5b15a9f19a63757a63cf059b279cb Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5agg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5cairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5cairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e459460c0161074838ecbf615d98caee55013ba8 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qt5cairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtagg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtagg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..919c5d8e3265432ff6fbcac148a06c16f9a1d8e8 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtagg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtcairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtcairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb4239bd99eef8e9dea5e275033ad168414b5ec7 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_qtcairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_svg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_svg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f77c1385a6cc1df2df53961c5bcbf32d154238eb Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_svg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_template.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_template.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d0093664bd21b55c855a1e44fc861d30184739e Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_template.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkagg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkagg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30394928234278069dfdd2e30aae1631dcbcd94c Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkagg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkcairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkcairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40c9be44a33fbcbfc6628e5a52e3218955a80af0 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_tkcairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a4a6492ef32404ae65cb2c81921b0a6ea502575b Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg_core.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg_core.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0eefc4a439f12caccc6315f6acd593fbb0994abc Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_webagg_core.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wx.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wx.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0e645c8d11c882b3bdf093c1574da13706391ae Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wx.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxagg.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxagg.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99844240f4e0186926731f804726845a3f74b074 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxagg.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxcairo.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxcairo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e48d71da50e5903f0c9109d8775bfe79f0c672f4 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/backend_wxcairo.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/qt_compat.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/qt_compat.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b76dd4bdc18dd413a6ac4a8b96e2cae7ea585bc4 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/qt_compat.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/__pycache__/registry.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae216579f62186846c237ccb6bd52641d29b12fe Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/__pycache__/registry.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/_backend_agg.pyi b/lib/python3.12/site-packages/matplotlib/backends/_backend_agg.pyi new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/matplotlib/backends/_backend_pdf_ps.py b/lib/python3.12/site-packages/matplotlib/backends/_backend_pdf_ps.py new file mode 100644 index 0000000000000000000000000000000000000000..a2a878d54156f7fb54ad31c1c109f7a5be23e0ee --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/_backend_pdf_ps.py @@ -0,0 +1,189 @@ +""" +Common functionality between the PDF and PS backends. +""" + +from io import BytesIO +import functools +import logging + +from fontTools import subset + +import matplotlib as mpl +from .. import font_manager, ft2font +from .._afm import AFM +from ..backend_bases import RendererBase + + +@functools.lru_cache(50) +def _cached_get_afm_from_fname(fname): + with open(fname, "rb") as fh: + return AFM(fh) + + +def get_glyphs_subset(fontfile, characters): + """ + Subset a TTF font + + Reads the named fontfile and restricts the font to the characters. + + Parameters + ---------- + fontfile : str + Path to the font file + characters : str + Continuous set of characters to include in subset + + Returns + ------- + fontTools.ttLib.ttFont.TTFont + An open font object representing the subset, which needs to + be closed by the caller. + """ + + options = subset.Options(glyph_names=True, recommended_glyphs=True) + + # Prevent subsetting extra tables. + options.drop_tables += [ + 'FFTM', # FontForge Timestamp. + 'PfEd', # FontForge personal table. + 'BDF', # X11 BDF header. + 'meta', # Metadata stores design/supported languages (meaningless for subsets). + 'MERG', # Merge Table. + 'TSIV', # Microsoft Visual TrueType extension. + 'Zapf', # Information about the individual glyphs in the font. + 'bdat', # The bitmap data table. + 'bloc', # The bitmap location table. + 'cidg', # CID to Glyph ID table (Apple Advanced Typography). + 'fdsc', # The font descriptors table. + 'feat', # Feature name table (Apple Advanced Typography). + 'fmtx', # The Font Metrics Table. + 'fond', # Data-fork font information (Apple Advanced Typography). + 'just', # The justification table (Apple Advanced Typography). + 'kerx', # An extended kerning table (Apple Advanced Typography). + 'ltag', # Language Tag. + 'morx', # Extended Glyph Metamorphosis Table. + 'trak', # Tracking table. + 'xref', # The cross-reference table (some Apple font tooling information). + ] + # if fontfile is a ttc, specify font number + if fontfile.endswith(".ttc"): + options.font_number = 0 + + font = subset.load_font(fontfile, options) + subsetter = subset.Subsetter(options=options) + subsetter.populate(text=characters) + subsetter.subset(font) + return font + + +def font_as_file(font): + """ + Convert a TTFont object into a file-like object. + + Parameters + ---------- + font : fontTools.ttLib.ttFont.TTFont + A font object + + Returns + ------- + BytesIO + A file object with the font saved into it + """ + fh = BytesIO() + font.save(fh, reorderTables=False) + return fh + + +class CharacterTracker: + """ + Helper for font subsetting by the pdf and ps backends. + + Maintains a mapping of font paths to the set of character codepoints that + are being used from that font. + """ + + def __init__(self): + self.used = {} + + def track(self, font, s): + """Record that string *s* is being typeset using font *font*.""" + char_to_font = font._get_fontmap(s) + for _c, _f in char_to_font.items(): + self.used.setdefault(_f.fname, set()).add(ord(_c)) + + def track_glyph(self, font, glyph): + """Record that codepoint *glyph* is being typeset using font *font*.""" + self.used.setdefault(font.fname, set()).add(glyph) + + +class RendererPDFPSBase(RendererBase): + # The following attributes must be defined by the subclasses: + # - _afm_font_dir + # - _use_afm_rc_name + + def __init__(self, width, height): + super().__init__() + self.width = width + self.height = height + + def flipy(self): + # docstring inherited + return False # y increases from bottom to top. + + def option_scale_image(self): + # docstring inherited + return True # PDF and PS support arbitrary image scaling. + + def option_image_nocomposite(self): + # docstring inherited + # Decide whether to composite image based on rcParam value. + return not mpl.rcParams["image.composite_image"] + + def get_canvas_width_height(self): + # docstring inherited + return self.width * 72.0, self.height * 72.0 + + def get_text_width_height_descent(self, s, prop, ismath): + # docstring inherited + if ismath == "TeX": + return super().get_text_width_height_descent(s, prop, ismath) + elif ismath: + parse = self._text2path.mathtext_parser.parse(s, 72, prop) + return parse.width, parse.height, parse.depth + elif mpl.rcParams[self._use_afm_rc_name]: + font = self._get_font_afm(prop) + l, b, w, h, d = font.get_str_bbox_and_descent(s) + scale = prop.get_size_in_points() / 1000 + w *= scale + h *= scale + d *= scale + return w, h, d + else: + font = self._get_font_ttf(prop) + font.set_text(s, 0.0, flags=ft2font.LoadFlags.NO_HINTING) + w, h = font.get_width_height() + d = font.get_descent() + scale = 1 / 64 + w *= scale + h *= scale + d *= scale + return w, h, d + + def _get_font_afm(self, prop): + fname = font_manager.findfont( + prop, fontext="afm", directory=self._afm_font_dir) + return _cached_get_afm_from_fname(fname) + + def _get_font_ttf(self, prop): + fnames = font_manager.fontManager._find_fonts_by_props(prop) + try: + font = font_manager.get_font(fnames) + font.clear() + font.set_size(prop.get_size_in_points(), 72) + return font + except RuntimeError: + logging.getLogger(__name__).warning( + "The PostScript/PDF backend does not currently " + "support the selected font (%s).", fnames) + raise diff --git a/lib/python3.12/site-packages/matplotlib/backends/_backend_tk.py b/lib/python3.12/site-packages/matplotlib/backends/_backend_tk.py new file mode 100644 index 0000000000000000000000000000000000000000..0bbff1379ffae2adb946a2987d92966b034e01f1 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/_backend_tk.py @@ -0,0 +1,1077 @@ +import uuid +import weakref +from contextlib import contextmanager +import logging +import math +import os.path +import pathlib +import sys +import tkinter as tk +import tkinter.filedialog +import tkinter.font +import tkinter.messagebox +from tkinter.simpledialog import SimpleDialog + +import numpy as np +from PIL import Image, ImageTk + +import matplotlib as mpl +from matplotlib import _api, backend_tools, cbook, _c_internal_utils +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, + TimerBase, ToolContainerBase, cursors, _Mode, MouseButton, + CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) +from matplotlib._pylab_helpers import Gcf +from . import _tkagg +from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET + + +_log = logging.getLogger(__name__) +cursord = { + cursors.MOVE: "fleur", + cursors.HAND: "hand2", + cursors.POINTER: "arrow", + cursors.SELECT_REGION: "crosshair", + cursors.WAIT: "watch", + cursors.RESIZE_HORIZONTAL: "sb_h_double_arrow", + cursors.RESIZE_VERTICAL: "sb_v_double_arrow", +} + + +@contextmanager +def _restore_foreground_window_at_end(): + foreground = _c_internal_utils.Win32_GetForegroundWindow() + try: + yield + finally: + if foreground and mpl.rcParams['tk.window_focus']: + _c_internal_utils.Win32_SetForegroundWindow(foreground) + + +_blit_args = {} +# Initialize to a non-empty string that is not a Tcl command +_blit_tcl_name = "mpl_blit_" + uuid.uuid4().hex + + +def _blit(argsid): + """ + Thin wrapper to blit called via tkapp.call. + + *argsid* is a unique string identifier to fetch the correct arguments from + the ``_blit_args`` dict, since arguments cannot be passed directly. + """ + photoimage, data, offsets, bbox, comp_rule = _blit_args.pop(argsid) + if not photoimage.tk.call("info", "commands", photoimage): + return + _tkagg.blit(photoimage.tk.interpaddr(), str(photoimage), data, comp_rule, offsets, + bbox) + + +def blit(photoimage, aggimage, offsets, bbox=None): + """ + Blit *aggimage* to *photoimage*. + + *offsets* is a tuple describing how to fill the ``offset`` field of the + ``Tk_PhotoImageBlock`` struct: it should be (0, 1, 2, 3) for RGBA8888 data, + (2, 1, 0, 3) for little-endian ARBG32 (i.e. GBRA8888) data and (1, 2, 3, 0) + for big-endian ARGB32 (i.e. ARGB8888) data. + + If *bbox* is passed, it defines the region that gets blitted. That region + will be composed with the previous data according to the alpha channel. + Blitting will be clipped to pixels inside the canvas, including silently + doing nothing if the *bbox* region is entirely outside the canvas. + + Tcl events must be dispatched to trigger a blit from a non-Tcl thread. + """ + data = np.asarray(aggimage) + height, width = data.shape[:2] + if bbox is not None: + (x1, y1), (x2, y2) = bbox.__array__() + x1 = max(math.floor(x1), 0) + x2 = min(math.ceil(x2), width) + y1 = max(math.floor(y1), 0) + y2 = min(math.ceil(y2), height) + if (x1 > x2) or (y1 > y2): + return + bboxptr = (x1, x2, y1, y2) + comp_rule = TK_PHOTO_COMPOSITE_OVERLAY + else: + bboxptr = (0, width, 0, height) + comp_rule = TK_PHOTO_COMPOSITE_SET + + # NOTE: _tkagg.blit is thread unsafe and will crash the process if called + # from a thread (GH#13293). Instead of blanking and blitting here, + # use tkapp.call to post a cross-thread event if this function is called + # from a non-Tcl thread. + + # tkapp.call coerces all arguments to strings, so to avoid string parsing + # within _blit, pack up the arguments into a global data structure. + args = photoimage, data, offsets, bboxptr, comp_rule + # Need a unique key to avoid thread races. + # Again, make the key a string to avoid string parsing in _blit. + argsid = str(id(args)) + _blit_args[argsid] = args + + try: + photoimage.tk.call(_blit_tcl_name, argsid) + except tk.TclError as e: + if "invalid command name" not in str(e): + raise + photoimage.tk.createcommand(_blit_tcl_name, _blit) + photoimage.tk.call(_blit_tcl_name, argsid) + + +class TimerTk(TimerBase): + """Subclass of `backend_bases.TimerBase` using Tk timer events.""" + + def __init__(self, parent, *args, **kwargs): + self._timer = None + super().__init__(*args, **kwargs) + self.parent = parent + + def _timer_start(self): + self._timer_stop() + self._timer = self.parent.after(self._interval, self._on_timer) + + def _timer_stop(self): + if self._timer is not None: + self.parent.after_cancel(self._timer) + self._timer = None + + def _on_timer(self): + super()._on_timer() + # Tk after() is only a single shot, so we need to add code here to + # reset the timer if we're not operating in single shot mode. However, + # if _timer is None, this means that _timer_stop has been called; so + # don't recreate the timer in that case. + if not self._single and self._timer: + if self._interval > 0: + self._timer = self.parent.after(self._interval, self._on_timer) + else: + # Edge case: Tcl after 0 *prepends* events to the queue + # so a 0 interval does not allow any other events to run. + # This incantation is cancellable and runs as fast as possible + # while also allowing events and drawing every frame. GH#18236 + self._timer = self.parent.after_idle( + lambda: self.parent.after(self._interval, self._on_timer) + ) + else: + self._timer = None + + +class FigureCanvasTk(FigureCanvasBase): + required_interactive_framework = "tk" + manager_class = _api.classproperty(lambda cls: FigureManagerTk) + + def __init__(self, figure=None, master=None): + super().__init__(figure) + self._idle_draw_id = None + self._event_loop_id = None + w, h = self.get_width_height(physical=True) + self._tkcanvas = tk.Canvas( + master=master, background="white", + width=w, height=h, borderwidth=0, highlightthickness=0) + self._tkphoto = tk.PhotoImage( + master=self._tkcanvas, width=w, height=h) + self._tkcanvas_image_region = self._tkcanvas.create_image( + w//2, h//2, image=self._tkphoto) + self._tkcanvas.bind("", self.resize) + self._tkcanvas.bind("", self._update_device_pixel_ratio) + self._tkcanvas.bind("", self.key_press) + self._tkcanvas.bind("", self.motion_notify_event) + self._tkcanvas.bind("", self.enter_notify_event) + self._tkcanvas.bind("", self.leave_notify_event) + self._tkcanvas.bind("", self.key_release) + for name in ["", "", ""]: + self._tkcanvas.bind(name, self.button_press_event) + for name in [ + "", "", ""]: + self._tkcanvas.bind(name, self.button_dblclick_event) + for name in [ + "", "", ""]: + self._tkcanvas.bind(name, self.button_release_event) + + # Mouse wheel on Linux generates button 4/5 events + for name in "", "": + self._tkcanvas.bind(name, self.scroll_event) + # Mouse wheel for windows goes to the window with the focus. + # Since the canvas won't usually have the focus, bind the + # event to the window containing the canvas instead. + # See https://wiki.tcl-lang.org/3893 (mousewheel) for details + root = self._tkcanvas.winfo_toplevel() + + # Prevent long-lived references via tkinter callback structure GH-24820 + weakself = weakref.ref(self) + weakroot = weakref.ref(root) + + def scroll_event_windows(event): + self = weakself() + if self is None: + root = weakroot() + if root is not None: + root.unbind("", scroll_event_windows_id) + return + return self.scroll_event_windows(event) + scroll_event_windows_id = root.bind("", scroll_event_windows, "+") + + # Can't get destroy events by binding to _tkcanvas. Therefore, bind + # to the window and filter. + def filter_destroy(event): + self = weakself() + if self is None: + root = weakroot() + if root is not None: + root.unbind("", filter_destroy_id) + return + if event.widget is self._tkcanvas: + CloseEvent("close_event", self)._process() + filter_destroy_id = root.bind("", filter_destroy, "+") + + self._tkcanvas.focus_set() + + self._rubberband_rect_black = None + self._rubberband_rect_white = None + + def _update_device_pixel_ratio(self, event=None): + ratio = None + if sys.platform == 'win32': + # Tk gives scaling with respect to 72 DPI, but Windows screens are + # scaled vs 96 dpi, and pixel ratio settings are given in whole + # percentages, so round to 2 digits. + ratio = round(self._tkcanvas.tk.call('tk', 'scaling') / (96 / 72), 2) + elif sys.platform == "linux": + ratio = self._tkcanvas.winfo_fpixels('1i') / 96 + if ratio is not None and self._set_device_pixel_ratio(ratio): + # The easiest way to resize the canvas is to resize the canvas + # widget itself, since we implement all the logic for resizing the + # canvas backing store on that event. + w, h = self.get_width_height(physical=True) + self._tkcanvas.configure(width=w, height=h) + + def resize(self, event): + width, height = event.width, event.height + + # compute desired figure size in inches + dpival = self.figure.dpi + winch = width / dpival + hinch = height / dpival + self.figure.set_size_inches(winch, hinch, forward=False) + + self._tkcanvas.delete(self._tkcanvas_image_region) + self._tkphoto.configure(width=int(width), height=int(height)) + self._tkcanvas_image_region = self._tkcanvas.create_image( + int(width / 2), int(height / 2), image=self._tkphoto) + ResizeEvent("resize_event", self)._process() + self.draw_idle() + + def draw_idle(self): + # docstring inherited + if self._idle_draw_id: + return + + def idle_draw(*args): + try: + self.draw() + finally: + self._idle_draw_id = None + + self._idle_draw_id = self._tkcanvas.after_idle(idle_draw) + + def get_tk_widget(self): + """ + Return the Tk widget used to implement FigureCanvasTkAgg. + + Although the initial implementation uses a Tk canvas, this routine + is intended to hide that fact. + """ + return self._tkcanvas + + def _event_mpl_coords(self, event): + # calling canvasx/canvasy allows taking scrollbars into account (i.e. + # the top of the widget may have been scrolled out of view). + return (self._tkcanvas.canvasx(event.x), + # flipy so y=0 is bottom of canvas + self.figure.bbox.height - self._tkcanvas.canvasy(event.y)) + + def motion_notify_event(self, event): + MouseEvent("motion_notify_event", self, + *self._event_mpl_coords(event), + buttons=self._mpl_buttons(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def enter_notify_event(self, event): + LocationEvent("figure_enter_event", self, + *self._event_mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def leave_notify_event(self, event): + LocationEvent("figure_leave_event", self, + *self._event_mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def button_press_event(self, event, dblclick=False): + # set focus to the canvas so that it can receive keyboard events + self._tkcanvas.focus_set() + + num = getattr(event, 'num', None) + if sys.platform == 'darwin': # 2 and 3 are reversed. + num = {2: 3, 3: 2}.get(num, num) + MouseEvent("button_press_event", self, + *self._event_mpl_coords(event), num, dblclick=dblclick, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def button_dblclick_event(self, event): + self.button_press_event(event, dblclick=True) + + def button_release_event(self, event): + num = getattr(event, 'num', None) + if sys.platform == 'darwin': # 2 and 3 are reversed. + num = {2: 3, 3: 2}.get(num, num) + MouseEvent("button_release_event", self, + *self._event_mpl_coords(event), num, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def scroll_event(self, event): + num = getattr(event, 'num', None) + step = 1 if num == 4 else -1 if num == 5 else 0 + MouseEvent("scroll_event", self, + *self._event_mpl_coords(event), step=step, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def scroll_event_windows(self, event): + """MouseWheel event processor""" + # need to find the window that contains the mouse + w = event.widget.winfo_containing(event.x_root, event.y_root) + if w != self._tkcanvas: + return + x = self._tkcanvas.canvasx(event.x_root - w.winfo_rootx()) + y = (self.figure.bbox.height + - self._tkcanvas.canvasy(event.y_root - w.winfo_rooty())) + step = event.delta / 120 + MouseEvent("scroll_event", self, + x, y, step=step, modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + @staticmethod + def _mpl_buttons(event): # See _mpl_modifiers. + # NOTE: This fails to report multiclicks on macOS; only one button is + # reported (multiclicks work correctly on Linux & Windows). + modifiers = [ + # macOS appears to swap right and middle (look for "Swap buttons + # 2/3" in tk/macosx/tkMacOSXMouseEvent.c). + (MouseButton.LEFT, 1 << 8), + (MouseButton.RIGHT, 1 << 9), + (MouseButton.MIDDLE, 1 << 10), + (MouseButton.BACK, 1 << 11), + (MouseButton.FORWARD, 1 << 12), + ] if sys.platform == "darwin" else [ + (MouseButton.LEFT, 1 << 8), + (MouseButton.MIDDLE, 1 << 9), + (MouseButton.RIGHT, 1 << 10), + (MouseButton.BACK, 1 << 11), + (MouseButton.FORWARD, 1 << 12), + ] + # State *before* press/release. + return [name for name, mask in modifiers if event.state & mask] + + @staticmethod + def _mpl_modifiers(event, *, exclude=None): + # Add modifier keys to the key string. Bit values are inferred from + # the implementation of tkinter.Event.__repr__ (1, 2, 4, 8, ... = + # Shift, Lock, Control, Mod1, ..., Mod5, Button1, ..., Button5) + # In general, the modifier key is excluded from the modifier flag, + # however this is not the case on "darwin", so double check that + # we aren't adding repeat modifier flags to a modifier key. + modifiers = [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 17, "alt"), + ("shift", 1 << 0, "shift"), + ] if sys.platform == "win32" else [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 4, "alt"), + ("shift", 1 << 0, "shift"), + ("cmd", 1 << 3, "cmd"), + ] if sys.platform == "darwin" else [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 3, "alt"), + ("shift", 1 << 0, "shift"), + ("super", 1 << 6, "super"), + ] + return [name for name, mask, key in modifiers + if event.state & mask and exclude != key] + + def _get_key(self, event): + unikey = event.char + key = cbook._unikey_or_keysym_to_mplkey(unikey, event.keysym) + if key is not None: + mods = self._mpl_modifiers(event, exclude=key) + # shift is not added to the keys as this is already accounted for. + if "shift" in mods and unikey: + mods.remove("shift") + return "+".join([*mods, key]) + + def key_press(self, event): + KeyEvent("key_press_event", self, + self._get_key(event), *self._event_mpl_coords(event), + guiEvent=event)._process() + + def key_release(self, event): + KeyEvent("key_release_event", self, + self._get_key(event), *self._event_mpl_coords(event), + guiEvent=event)._process() + + def new_timer(self, *args, **kwargs): + # docstring inherited + return TimerTk(self._tkcanvas, *args, **kwargs) + + def flush_events(self): + # docstring inherited + self._tkcanvas.update() + + def start_event_loop(self, timeout=0): + # docstring inherited + if timeout > 0: + milliseconds = int(1000 * timeout) + if milliseconds > 0: + self._event_loop_id = self._tkcanvas.after( + milliseconds, self.stop_event_loop) + else: + self._event_loop_id = self._tkcanvas.after_idle( + self.stop_event_loop) + self._tkcanvas.mainloop() + + def stop_event_loop(self): + # docstring inherited + if self._event_loop_id: + self._tkcanvas.after_cancel(self._event_loop_id) + self._event_loop_id = None + self._tkcanvas.quit() + + def set_cursor(self, cursor): + try: + self._tkcanvas.configure(cursor=cursord[cursor]) + except tkinter.TclError: + pass + + +class FigureManagerTk(FigureManagerBase): + """ + Attributes + ---------- + canvas : `FigureCanvas` + The FigureCanvas instance + num : int or str + The Figure number + toolbar : tk.Toolbar + The tk.Toolbar + window : tk.Window + The tk.Window + """ + + _owns_mainloop = False + + def __init__(self, canvas, num, window): + self.window = window + super().__init__(canvas, num) + self.window.withdraw() + # packing toolbar first, because if space is getting low, last packed + # widget is getting shrunk first (-> the canvas) + self.canvas._tkcanvas.pack(side=tk.TOP, fill=tk.BOTH, expand=1) + + # If the window has per-monitor DPI awareness, then setup a Tk variable + # to store the DPI, which will be updated by the C code, and the trace + # will handle it on the Python side. + window_frame = int(window.wm_frame(), 16) + self._window_dpi = tk.IntVar(master=window, value=96, + name=f'window_dpi{window_frame}') + self._window_dpi_cbname = '' + if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()): + self._window_dpi_cbname = self._window_dpi.trace_add( + 'write', self._update_window_dpi) + + self._shown = False + + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + # docstring inherited + with _restore_foreground_window_at_end(): + if cbook._get_running_interactive_framework() is None: + cbook._setup_new_guiapp() + _c_internal_utils.Win32_SetProcessDpiAwareness_max() + window = tk.Tk(className="matplotlib") + window.withdraw() + + # Put a Matplotlib icon on the window rather than the default tk + # icon. See https://www.tcl.tk/man/tcl/TkCmd/wm.html#M50 + # + # `ImageTk` can be replaced with `tk` whenever the minimum + # supported Tk version is increased to 8.6, as Tk 8.6+ natively + # supports PNG images. + icon_fname = str(cbook._get_data_path( + 'images/matplotlib.png')) + icon_img = ImageTk.PhotoImage(file=icon_fname, master=window) + + icon_fname_large = str(cbook._get_data_path( + 'images/matplotlib_large.png')) + icon_img_large = ImageTk.PhotoImage( + file=icon_fname_large, master=window) + + window.iconphoto(False, icon_img_large, icon_img) + + canvas = canvas_class(figure, master=window) + manager = cls(canvas, num, window) + if mpl.is_interactive(): + manager.show() + canvas.draw_idle() + return manager + + @classmethod + def start_main_loop(cls): + managers = Gcf.get_all_fig_managers() + if managers: + first_manager = managers[0] + manager_class = type(first_manager) + if manager_class._owns_mainloop: + return + manager_class._owns_mainloop = True + try: + first_manager.window.mainloop() + finally: + manager_class._owns_mainloop = False + + def _update_window_dpi(self, *args): + newdpi = self._window_dpi.get() + self.window.call('tk', 'scaling', newdpi / 72) + if self.toolbar and hasattr(self.toolbar, '_rescale'): + self.toolbar._rescale() + self.canvas._update_device_pixel_ratio() + + def resize(self, width, height): + max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023 + + if (width > max_size or height > max_size) and sys.platform == 'linux': + raise ValueError( + 'You have requested to resize the ' + f'Tk window to ({width}, {height}), one of which ' + f'is bigger than {max_size}. At larger sizes xorg will ' + 'either exit with an error on newer versions (~1.20) or ' + 'cause corruption on older version (~1.19). We ' + 'do not expect a window over a million pixel wide or tall ' + 'to be intended behavior.') + self.canvas._tkcanvas.configure(width=width, height=height) + + def show(self): + with _restore_foreground_window_at_end(): + if not self._shown: + def destroy(*args): + Gcf.destroy(self) + self.window.protocol("WM_DELETE_WINDOW", destroy) + self.window.deiconify() + self.canvas._tkcanvas.focus_set() + else: + self.canvas.draw_idle() + if mpl.rcParams['figure.raise_window']: + self.canvas.manager.window.attributes('-topmost', 1) + self.canvas.manager.window.attributes('-topmost', 0) + self._shown = True + + def destroy(self, *args): + if self.canvas._idle_draw_id: + self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id) + if self.canvas._event_loop_id: + self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id) + if self._window_dpi_cbname: + self._window_dpi.trace_remove('write', self._window_dpi_cbname) + + # NOTE: events need to be flushed before issuing destroy (GH #9956), + # however, self.window.update() can break user code. An async callback + # is the safest way to achieve a complete draining of the event queue, + # but it leaks if no tk event loop is running. Therefore we explicitly + # check for an event loop and choose our best guess. + def delayed_destroy(): + self.window.destroy() + + if self._owns_mainloop and not Gcf.get_num_fig_managers(): + self.window.quit() + + if cbook._get_running_interactive_framework() == "tk": + # "after idle after 0" avoids Tcl error/race (GH #19940) + self.window.after_idle(self.window.after, 0, delayed_destroy) + else: + self.window.update() + delayed_destroy() + + def get_window_title(self): + return self.window.wm_title() + + def set_window_title(self, title): + self.window.wm_title(title) + + def full_screen_toggle(self): + is_fullscreen = bool(self.window.attributes('-fullscreen')) + self.window.attributes('-fullscreen', not is_fullscreen) + + +class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame): + def __init__(self, canvas, window=None, *, pack_toolbar=True): + """ + Parameters + ---------- + canvas : `FigureCanvas` + The figure canvas on which to operate. + window : tk.Window + The tk.Window which owns this toolbar. + pack_toolbar : bool, default: True + If True, add the toolbar to the parent's pack manager's packing + list during initialization with ``side="bottom"`` and ``fill="x"``. + If you want to use the toolbar with a different layout manager, use + ``pack_toolbar=False``. + """ + + if window is None: + window = canvas.get_tk_widget().master + tk.Frame.__init__(self, master=window, borderwidth=2, + width=int(canvas.figure.bbox.width), height=50) + + self._buttons = {} + for text, tooltip_text, image_file, callback in self.toolitems: + if text is None: + # Add a spacer; return value is unused. + self._Spacer() + else: + self._buttons[text] = button = self._Button( + text, + str(cbook._get_data_path(f"images/{image_file}.png")), + toggle=callback in ["zoom", "pan"], + command=getattr(self, callback), + ) + if tooltip_text is not None: + add_tooltip(button, tooltip_text) + + self._label_font = tkinter.font.Font(root=window, size=10) + + # This filler item ensures the toolbar is always at least two text + # lines high. Otherwise the canvas gets redrawn as the mouse hovers + # over images because those use two-line messages which resize the + # toolbar. + label = tk.Label(master=self, font=self._label_font, + text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') + label.pack(side=tk.RIGHT) + + self.message = tk.StringVar(master=self) + self._message_label = tk.Label(master=self, font=self._label_font, + textvariable=self.message, + justify=tk.RIGHT) + self._message_label.pack(side=tk.RIGHT) + + NavigationToolbar2.__init__(self, canvas) + if pack_toolbar: + self.pack(side=tk.BOTTOM, fill=tk.X) + + def _rescale(self): + """ + Scale all children of the toolbar to current DPI setting. + + Before this is called, the Tk scaling setting will have been updated to + match the new DPI. Tk widgets do not update for changes to scaling, but + all measurements made after the change will match the new scaling. Thus + this function re-applies all the same sizes in points, which Tk will + scale correctly to pixels. + """ + for widget in self.winfo_children(): + if isinstance(widget, (tk.Button, tk.Checkbutton)): + if hasattr(widget, '_image_file'): + # Explicit class because ToolbarTk calls _rescale. + NavigationToolbar2Tk._set_image_for_button(self, widget) + else: + # Text-only button is handled by the font setting instead. + pass + elif isinstance(widget, tk.Frame): + widget.configure(height='18p') + widget.pack_configure(padx='3p') + elif isinstance(widget, tk.Label): + pass # Text is handled by the font setting instead. + else: + _log.warning('Unknown child class %s', widget.winfo_class) + self._label_font.configure(size=10) + + def _update_buttons_checked(self): + # sync button checkstates to match active mode + for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]: + if text in self._buttons: + if self.mode == mode: + self._buttons[text].select() # NOT .invoke() + else: + self._buttons[text].deselect() + + def pan(self, *args): + super().pan(*args) + self._update_buttons_checked() + + def zoom(self, *args): + super().zoom(*args) + self._update_buttons_checked() + + def set_message(self, s): + self.message.set(s) + + def draw_rubberband(self, event, x0, y0, x1, y1): + # Block copied from remove_rubberband for backend_tools convenience. + if self.canvas._rubberband_rect_white: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) + if self.canvas._rubberband_rect_black: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) + height = self.canvas.figure.bbox.height + y0 = height - y0 + y1 = height - y1 + self.canvas._rubberband_rect_black = ( + self.canvas._tkcanvas.create_rectangle( + x0, y0, x1, y1)) + self.canvas._rubberband_rect_white = ( + self.canvas._tkcanvas.create_rectangle( + x0, y0, x1, y1, outline='white', dash=(3, 3))) + + def remove_rubberband(self): + if self.canvas._rubberband_rect_white: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) + self.canvas._rubberband_rect_white = None + if self.canvas._rubberband_rect_black: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) + self.canvas._rubberband_rect_black = None + + def _set_image_for_button(self, button): + """ + Set the image for a button based on its pixel size. + + The pixel size is determined by the DPI scaling of the window. + """ + if button._image_file is None: + return + + # Allow _image_file to be relative to Matplotlib's "images" data + # directory. + path_regular = cbook._get_data_path('images', button._image_file) + path_large = path_regular.with_name( + path_regular.name.replace('.png', '_large.png')) + size = button.winfo_pixels('18p') + + # Nested functions because ToolbarTk calls _Button. + def _get_color(color_name): + # `winfo_rgb` returns an (r, g, b) tuple in the range 0-65535 + return button.winfo_rgb(button.cget(color_name)) + + def _is_dark(color): + if isinstance(color, str): + color = _get_color(color) + return max(color) < 65535 / 2 + + def _recolor_icon(image, color): + image_data = np.asarray(image).copy() + black_mask = (image_data[..., :3] == 0).all(axis=-1) + image_data[black_mask, :3] = color + return Image.fromarray(image_data, mode="RGBA") + + # Use the high-resolution (48x48 px) icon if it exists and is needed + with Image.open(path_large if (size > 24 and path_large.exists()) + else path_regular) as im: + # assure a RGBA image as foreground color is RGB + im = im.convert("RGBA") + image = ImageTk.PhotoImage(im.resize((size, size)), master=self) + button._ntimage = image + + # create a version of the icon with the button's text color + foreground = (255 / 65535) * np.array( + button.winfo_rgb(button.cget("foreground"))) + im_alt = _recolor_icon(im, foreground) + image_alt = ImageTk.PhotoImage( + im_alt.resize((size, size)), master=self) + button._ntimage_alt = image_alt + + if _is_dark("background"): + # For Checkbuttons, we need to set `image` and `selectimage` at + # the same time. Otherwise, when updating the `image` option + # (such as when changing DPI), if the old `selectimage` has + # just been overwritten, Tk will throw an error. + image_kwargs = {"image": image_alt} + else: + image_kwargs = {"image": image} + # Checkbuttons may switch the background to `selectcolor` in the + # checked state, so check separately which image it needs to use in + # that state to still ensure enough contrast with the background. + if ( + isinstance(button, tk.Checkbutton) + and button.cget("selectcolor") != "" + ): + if self._windowingsystem != "x11": + selectcolor = "selectcolor" + else: + # On X11, selectcolor isn't used directly for indicator-less + # buttons. See `::tk::CheckEnter` in the Tk button.tcl source + # code for details. + r1, g1, b1 = _get_color("selectcolor") + r2, g2, b2 = _get_color("activebackground") + selectcolor = ((r1+r2)/2, (g1+g2)/2, (b1+b2)/2) + if _is_dark(selectcolor): + image_kwargs["selectimage"] = image_alt + else: + image_kwargs["selectimage"] = image + + button.configure(**image_kwargs, height='18p', width='18p') + + def _Button(self, text, image_file, toggle, command): + if not toggle: + b = tk.Button( + master=self, text=text, command=command, + relief="flat", overrelief="groove", borderwidth=1, + ) + else: + # There is a bug in tkinter included in some python 3.6 versions + # that without this variable, produces a "visual" toggling of + # other near checkbuttons + # https://bugs.python.org/issue29402 + # https://bugs.python.org/issue25684 + var = tk.IntVar(master=self) + b = tk.Checkbutton( + master=self, text=text, command=command, indicatoron=False, + variable=var, offrelief="flat", overrelief="groove", + borderwidth=1 + ) + b.var = var + b._image_file = image_file + if image_file is not None: + # Explicit class because ToolbarTk calls _Button. + NavigationToolbar2Tk._set_image_for_button(self, b) + else: + b.configure(font=self._label_font) + b.pack(side=tk.LEFT) + return b + + def _Spacer(self): + # Buttons are also 18pt high. + s = tk.Frame(master=self, height='18p', relief=tk.RIDGE, bg='DarkGray') + s.pack(side=tk.LEFT, padx='3p') + return s + + def save_figure(self, *args): + filetypes = self.canvas.get_supported_filetypes_grouped() + tk_filetypes = [ + (name, " ".join(f"*.{ext}" for ext in exts)) + for name, exts in sorted(filetypes.items()) + ] + + default_extension = self.canvas.get_default_filetype() + default_filetype = self.canvas.get_supported_filetypes()[default_extension] + filetype_variable = tk.StringVar(self.canvas.get_tk_widget(), default_filetype) + + # adding a default extension seems to break the + # asksaveasfilename dialog when you choose various save types + # from the dropdown. Passing in the empty string seems to + # work - JDH! + # defaultextension = self.canvas.get_default_filetype() + defaultextension = '' + initialdir = os.path.expanduser(mpl.rcParams['savefig.directory']) + # get_default_filename() contains the default extension. On some platforms, + # choosing a different extension from the dropdown does not overwrite it, + # so we need to remove it to make the dropdown functional. + initialfile = pathlib.Path(self.canvas.get_default_filename()).stem + fname = tkinter.filedialog.asksaveasfilename( + master=self.canvas.get_tk_widget().master, + title='Save the figure', + filetypes=tk_filetypes, + defaultextension=defaultextension, + initialdir=initialdir, + initialfile=initialfile, + typevariable=filetype_variable + ) + + if fname in ["", ()]: + return None + # Save dir for next time, unless empty str (i.e., use cwd). + if initialdir != "": + mpl.rcParams['savefig.directory'] = ( + os.path.dirname(str(fname))) + + # If the filename contains an extension, let savefig() infer the file + # format from that. If it does not, use the selected dropdown option. + if pathlib.Path(fname).suffix[1:] != "": + extension = None + else: + extension = filetypes[filetype_variable.get()][0] + + try: + self.canvas.figure.savefig(fname, format=extension) + return fname + except Exception as e: + tkinter.messagebox.showerror("Error saving file", str(e)) + + def set_history_buttons(self): + state_map = {True: tk.NORMAL, False: tk.DISABLED} + can_back = self._nav_stack._pos > 0 + can_forward = self._nav_stack._pos < len(self._nav_stack) - 1 + if "Back" in self._buttons: + self._buttons['Back']['state'] = state_map[can_back] + if "Forward" in self._buttons: + self._buttons['Forward']['state'] = state_map[can_forward] + + +def add_tooltip(widget, text): + tipwindow = None + + def showtip(event): + """Display text in tooltip window.""" + nonlocal tipwindow + if tipwindow or not text: + return + x, y, _, _ = widget.bbox("insert") + x = x + widget.winfo_rootx() + widget.winfo_width() + y = y + widget.winfo_rooty() + tipwindow = tk.Toplevel(widget) + tipwindow.overrideredirect(1) + tipwindow.geometry(f"+{x}+{y}") + try: # For Mac OS + tipwindow.tk.call("::tk::unsupported::MacWindowStyle", + "style", tipwindow._w, + "help", "noActivates") + except tk.TclError: + pass + label = tk.Label(tipwindow, text=text, justify=tk.LEFT, + relief=tk.SOLID, borderwidth=1) + label.pack(ipadx=1) + + def hidetip(event): + nonlocal tipwindow + if tipwindow: + tipwindow.destroy() + tipwindow = None + + widget.bind("", showtip) + widget.bind("", hidetip) + + +@backend_tools._register_tool_class(FigureCanvasTk) +class RubberbandTk(backend_tools.RubberbandBase): + def draw_rubberband(self, x0, y0, x1, y1): + NavigationToolbar2Tk.draw_rubberband( + self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1) + + def remove_rubberband(self): + NavigationToolbar2Tk.remove_rubberband( + self._make_classic_style_pseudo_toolbar()) + + +class ToolbarTk(ToolContainerBase, tk.Frame): + def __init__(self, toolmanager, window=None): + ToolContainerBase.__init__(self, toolmanager) + if window is None: + window = self.toolmanager.canvas.get_tk_widget().master + xmin, xmax = self.toolmanager.canvas.figure.bbox.intervalx + height, width = 50, xmax - xmin + tk.Frame.__init__(self, master=window, + width=int(width), height=int(height), + borderwidth=2) + self._label_font = tkinter.font.Font(size=10) + # This filler item ensures the toolbar is always at least two text + # lines high. Otherwise the canvas gets redrawn as the mouse hovers + # over images because those use two-line messages which resize the + # toolbar. + label = tk.Label(master=self, font=self._label_font, + text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') + label.pack(side=tk.RIGHT) + self._message = tk.StringVar(master=self) + self._message_label = tk.Label(master=self, font=self._label_font, + textvariable=self._message) + self._message_label.pack(side=tk.RIGHT) + self._toolitems = {} + self.pack(side=tk.TOP, fill=tk.X) + self._groups = {} + + def _rescale(self): + return NavigationToolbar2Tk._rescale(self) + + def add_toolitem( + self, name, group, position, image_file, description, toggle): + frame = self._get_groupframe(group) + buttons = frame.pack_slaves() + if position >= len(buttons) or position < 0: + before = None + else: + before = buttons[position] + button = NavigationToolbar2Tk._Button(frame, name, image_file, toggle, + lambda: self._button_click(name)) + button.pack_configure(before=before) + if description is not None: + add_tooltip(button, description) + self._toolitems.setdefault(name, []) + self._toolitems[name].append(button) + + def _get_groupframe(self, group): + if group not in self._groups: + if self._groups: + self._add_separator() + frame = tk.Frame(master=self, borderwidth=0) + frame.pack(side=tk.LEFT, fill=tk.Y) + frame._label_font = self._label_font + self._groups[group] = frame + return self._groups[group] + + def _add_separator(self): + return NavigationToolbar2Tk._Spacer(self) + + def _button_click(self, name): + self.trigger_tool(name) + + def toggle_toolitem(self, name, toggled): + if name not in self._toolitems: + return + for toolitem in self._toolitems[name]: + if toggled: + toolitem.select() + else: + toolitem.deselect() + + def remove_toolitem(self, name): + for toolitem in self._toolitems.pop(name, []): + toolitem.pack_forget() + + def set_message(self, s): + self._message.set(s) + + +@backend_tools._register_tool_class(FigureCanvasTk) +class SaveFigureTk(backend_tools.SaveFigureBase): + def trigger(self, *args): + NavigationToolbar2Tk.save_figure( + self._make_classic_style_pseudo_toolbar()) + + +@backend_tools._register_tool_class(FigureCanvasTk) +class ConfigureSubplotsTk(backend_tools.ConfigureSubplotsBase): + def trigger(self, *args): + NavigationToolbar2Tk.configure_subplots(self) + + +@backend_tools._register_tool_class(FigureCanvasTk) +class HelpTk(backend_tools.ToolHelpBase): + def trigger(self, *args): + dialog = SimpleDialog( + self.figure.canvas._tkcanvas, self._get_help_text(), ["OK"]) + dialog.done = lambda num: dialog.frame.master.withdraw() + + +Toolbar = ToolbarTk +FigureManagerTk._toolbar2_class = NavigationToolbar2Tk +FigureManagerTk._toolmanager_toolbar_class = ToolbarTk + + +@_Backend.export +class _BackendTk(_Backend): + backend_version = tk.TkVersion + FigureCanvas = FigureCanvasTk + FigureManager = FigureManagerTk + mainloop = FigureManagerTk.start_main_loop diff --git a/lib/python3.12/site-packages/matplotlib/backends/_macosx.pyi b/lib/python3.12/site-packages/matplotlib/backends/_macosx.pyi new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_agg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_agg.py new file mode 100644 index 0000000000000000000000000000000000000000..c3742736926771328d526e1550a0e9ffefb9183d --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_agg.py @@ -0,0 +1,528 @@ +""" +An `Anti-Grain Geometry`_ (AGG) backend. + +Features that are implemented: + +* capstyles and join styles +* dashes +* linewidth +* lines, rectangles, ellipses +* clipping to a rectangle +* output to RGBA and Pillow-supported image formats +* alpha blending +* DPI scaling properly - everything scales properly (dashes, linewidths, etc) +* draw polygon +* freetype2 w/ ft2font + +Still TODO: + +* integrate screen dpi w/ ppi and text + +.. _Anti-Grain Geometry: http://agg.sourceforge.net/antigrain.com +""" + +from contextlib import nullcontext +from math import radians, cos, sin + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) +from matplotlib.font_manager import fontManager as _fontManager, get_font +from matplotlib.ft2font import LoadFlags +from matplotlib.mathtext import MathTextParser +from matplotlib.path import Path +from matplotlib.transforms import Bbox, BboxBase +from matplotlib.backends._backend_agg import RendererAgg as _RendererAgg + + +def get_hinting_flag(): + mapping = { + 'default': LoadFlags.DEFAULT, + 'no_autohint': LoadFlags.NO_AUTOHINT, + 'force_autohint': LoadFlags.FORCE_AUTOHINT, + 'no_hinting': LoadFlags.NO_HINTING, + True: LoadFlags.FORCE_AUTOHINT, + False: LoadFlags.NO_HINTING, + 'either': LoadFlags.DEFAULT, + 'native': LoadFlags.NO_AUTOHINT, + 'auto': LoadFlags.FORCE_AUTOHINT, + 'none': LoadFlags.NO_HINTING, + } + return mapping[mpl.rcParams['text.hinting']] + + +class RendererAgg(RendererBase): + """ + The renderer handles all the drawing primitives using a graphics + context instance that controls the colors/styles + """ + + def __init__(self, width, height, dpi): + super().__init__() + + self.dpi = dpi + self.width = width + self.height = height + self._renderer = _RendererAgg(int(width), int(height), dpi) + self._filter_renderers = [] + + self._update_methods() + self.mathtext_parser = MathTextParser('agg') + + self.bbox = Bbox.from_bounds(0, 0, self.width, self.height) + + def __getstate__(self): + # We only want to preserve the init keywords of the Renderer. + # Anything else can be re-created. + return {'width': self.width, 'height': self.height, 'dpi': self.dpi} + + def __setstate__(self, state): + self.__init__(state['width'], state['height'], state['dpi']) + + def _update_methods(self): + self.draw_gouraud_triangles = self._renderer.draw_gouraud_triangles + self.draw_image = self._renderer.draw_image + self.draw_markers = self._renderer.draw_markers + self.draw_path_collection = self._renderer.draw_path_collection + self.draw_quad_mesh = self._renderer.draw_quad_mesh + self.copy_from_bbox = self._renderer.copy_from_bbox + + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + nmax = mpl.rcParams['agg.path.chunksize'] # here at least for testing + npts = path.vertices.shape[0] + + if (npts > nmax > 100 and path.should_simplify and + rgbFace is None and gc.get_hatch() is None): + nch = np.ceil(npts / nmax) + chsize = int(np.ceil(npts / nch)) + i0 = np.arange(0, npts, chsize) + i1 = np.zeros_like(i0) + i1[:-1] = i0[1:] - 1 + i1[-1] = npts + for ii0, ii1 in zip(i0, i1): + v = path.vertices[ii0:ii1, :] + c = path.codes + if c is not None: + c = c[ii0:ii1] + c[0] = Path.MOVETO # move to end of last chunk + p = Path(v, c) + p.simplify_threshold = path.simplify_threshold + try: + self._renderer.draw_path(gc, p, transform, rgbFace) + except OverflowError: + msg = ( + "Exceeded cell block limit in Agg.\n\n" + "Please reduce the value of " + f"rcParams['agg.path.chunksize'] (currently {nmax}) " + "or increase the path simplification threshold" + "(rcParams['path.simplify_threshold'] = " + f"{mpl.rcParams['path.simplify_threshold']:.2f} by " + "default and path.simplify_threshold = " + f"{path.simplify_threshold:.2f} on the input)." + ) + raise OverflowError(msg) from None + else: + try: + self._renderer.draw_path(gc, path, transform, rgbFace) + except OverflowError: + cant_chunk = '' + if rgbFace is not None: + cant_chunk += "- cannot split filled path\n" + if gc.get_hatch() is not None: + cant_chunk += "- cannot split hatched path\n" + if not path.should_simplify: + cant_chunk += "- path.should_simplify is False\n" + if len(cant_chunk): + msg = ( + "Exceeded cell block limit in Agg, however for the " + "following reasons:\n\n" + f"{cant_chunk}\n" + "we cannot automatically split up this path to draw." + "\n\nPlease manually simplify your path." + ) + + else: + inc_threshold = ( + "or increase the path simplification threshold" + "(rcParams['path.simplify_threshold'] = " + f"{mpl.rcParams['path.simplify_threshold']} " + "by default and path.simplify_threshold " + f"= {path.simplify_threshold} " + "on the input)." + ) + if nmax > 100: + msg = ( + "Exceeded cell block limit in Agg. Please reduce " + "the value of rcParams['agg.path.chunksize'] " + f"(currently {nmax}) {inc_threshold}" + ) + else: + msg = ( + "Exceeded cell block limit in Agg. Please set " + "the value of rcParams['agg.path.chunksize'], " + f"(currently {nmax}) to be greater than 100 " + + inc_threshold + ) + + raise OverflowError(msg) from None + + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw mathtext using :mod:`matplotlib.mathtext`.""" + ox, oy, width, height, descent, font_image = \ + self.mathtext_parser.parse(s, self.dpi, prop, + antialiased=gc.get_antialiased()) + + xd = descent * sin(radians(angle)) + yd = descent * cos(radians(angle)) + x = round(x + ox + xd) + y = round(y - oy + yd) + self._renderer.draw_text_image(font_image, x, y + 1, angle, gc) + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + if ismath: + return self.draw_mathtext(gc, x, y, s, prop, angle) + font = self._prepare_font(prop) + # We pass '0' for angle here, since it will be rotated (in raster + # space) in the following call to draw_text_image). + font.set_text(s, 0, flags=get_hinting_flag()) + font.draw_glyphs_to_bitmap( + antialiased=gc.get_antialiased()) + d = font.get_descent() / 64.0 + # The descent needs to be adjusted for the angle. + xo, yo = font.get_bitmap_offset() + xo /= 64.0 + yo /= 64.0 + xd = d * sin(radians(angle)) + yd = d * cos(radians(angle)) + x = round(x + xo + xd) + y = round(y + yo + yd) + self._renderer.draw_text_image(font, x, y + 1, angle, gc) + + def get_text_width_height_descent(self, s, prop, ismath): + # docstring inherited + + _api.check_in_list(["TeX", True, False], ismath=ismath) + if ismath == "TeX": + return super().get_text_width_height_descent(s, prop, ismath) + + if ismath: + ox, oy, width, height, descent, font_image = \ + self.mathtext_parser.parse(s, self.dpi, prop) + return width, height, descent + + font = self._prepare_font(prop) + font.set_text(s, 0.0, flags=get_hinting_flag()) + w, h = font.get_width_height() # width and height of unrotated string + d = font.get_descent() + w /= 64.0 # convert from subpixels + h /= 64.0 + d /= 64.0 + return w, h, d + + def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): + # docstring inherited + # todo, handle props, angle, origins + size = prop.get_size_in_points() + + texmanager = self.get_texmanager() + + Z = texmanager.get_grey(s, size, self.dpi) + Z = np.array(Z * 255.0, np.uint8) + + w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX") + xd = d * sin(radians(angle)) + yd = d * cos(radians(angle)) + x = round(x + xd) + y = round(y + yd) + self._renderer.draw_text_image(Z, x, y, angle, gc) + + def get_canvas_width_height(self): + # docstring inherited + return self.width, self.height + + def _prepare_font(self, font_prop): + """ + Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size. + """ + font = get_font(_fontManager._find_fonts_by_props(font_prop)) + font.clear() + size = font_prop.get_size_in_points() + font.set_size(size, self.dpi) + return font + + def points_to_pixels(self, points): + # docstring inherited + return points * self.dpi / 72 + + def buffer_rgba(self): + return memoryview(self._renderer) + + def tostring_argb(self): + return np.asarray(self._renderer).take([3, 0, 1, 2], axis=2).tobytes() + + def clear(self): + self._renderer.clear() + + def option_image_nocomposite(self): + # docstring inherited + + # It is generally faster to composite each image directly to + # the Figure, and there's no file size benefit to compositing + # with the Agg backend + return True + + def option_scale_image(self): + # docstring inherited + return False + + def restore_region(self, region, bbox=None, xy=None): + """ + Restore the saved region. If bbox (instance of BboxBase, or + its extents) is given, only the region specified by the bbox + will be restored. *xy* (a pair of floats) optionally + specifies the new position (the LLC of the original region, + not the LLC of the bbox) where the region will be restored. + + >>> region = renderer.copy_from_bbox() + >>> x1, y1, x2, y2 = region.get_extents() + >>> renderer.restore_region(region, bbox=(x1+dx, y1, x2, y2), + ... xy=(x1-dx, y1)) + + """ + if bbox is not None or xy is not None: + if bbox is None: + x1, y1, x2, y2 = region.get_extents() + elif isinstance(bbox, BboxBase): + x1, y1, x2, y2 = bbox.extents + else: + x1, y1, x2, y2 = bbox + + if xy is None: + ox, oy = x1, y1 + else: + ox, oy = xy + + # The incoming data is float, but the _renderer type-checking wants + # to see integers. + self._renderer.restore_region(region, int(x1), int(y1), + int(x2), int(y2), int(ox), int(oy)) + + else: + self._renderer.restore_region(region) + + def start_filter(self): + """ + Start filtering. It simply creates a new canvas (the old one is saved). + """ + self._filter_renderers.append(self._renderer) + self._renderer = _RendererAgg(int(self.width), int(self.height), + self.dpi) + self._update_methods() + + def stop_filter(self, post_processing): + """ + Save the current canvas as an image and apply post processing. + + The *post_processing* function:: + + def post_processing(image, dpi): + # ny, nx, depth = image.shape + # image (numpy array) has RGBA channels and has a depth of 4. + ... + # create a new_image (numpy array of 4 channels, size can be + # different). The resulting image may have offsets from + # lower-left corner of the original image + return new_image, offset_x, offset_y + + The saved renderer is restored and the returned image from + post_processing is plotted (using draw_image) on it. + """ + orig_img = np.asarray(self.buffer_rgba()) + slice_y, slice_x = cbook._get_nonzero_slices(orig_img[..., 3]) + cropped_img = orig_img[slice_y, slice_x] + + self._renderer = self._filter_renderers.pop() + self._update_methods() + + if cropped_img.size: + img, ox, oy = post_processing(cropped_img / 255, self.dpi) + gc = self.new_gc() + if img.dtype.kind == 'f': + img = np.asarray(img * 255., np.uint8) + self._renderer.draw_image( + gc, slice_x.start + ox, int(self.height) - slice_y.stop + oy, + img[::-1]) + + +class FigureCanvasAgg(FigureCanvasBase): + # docstring inherited + + _lastKey = None # Overwritten per-instance on the first draw. + + def copy_from_bbox(self, bbox): + renderer = self.get_renderer() + return renderer.copy_from_bbox(bbox) + + def restore_region(self, region, bbox=None, xy=None): + renderer = self.get_renderer() + return renderer.restore_region(region, bbox, xy) + + def draw(self): + # docstring inherited + self.renderer = self.get_renderer() + self.renderer.clear() + # Acquire a lock on the shared font cache. + with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar + else nullcontext()): + self.figure.draw(self.renderer) + # A GUI class may be need to update a window using this draw, so + # don't forget to call the superclass. + super().draw() + + def get_renderer(self): + w, h = self.figure.bbox.size + key = w, h, self.figure.dpi + reuse_renderer = (self._lastKey == key) + if not reuse_renderer: + self.renderer = RendererAgg(w, h, self.figure.dpi) + self._lastKey = key + return self.renderer + + def tostring_argb(self): + """ + Get the image as ARGB `bytes`. + + `draw` must be called at least once before this function will work and + to update the renderer for any subsequent changes to the Figure. + """ + return self.renderer.tostring_argb() + + def buffer_rgba(self): + """ + Get the image as a `memoryview` to the renderer's buffer. + + `draw` must be called at least once before this function will work and + to update the renderer for any subsequent changes to the Figure. + """ + return self.renderer.buffer_rgba() + + def print_raw(self, filename_or_obj, *, metadata=None): + if metadata is not None: + raise ValueError("metadata not supported for raw/rgba") + FigureCanvasAgg.draw(self) + renderer = self.get_renderer() + with cbook.open_file_cm(filename_or_obj, "wb") as fh: + fh.write(renderer.buffer_rgba()) + + print_rgba = print_raw + + def _print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata=None): + """ + Draw the canvas, then save it using `.image.imsave` (to which + *pil_kwargs* and *metadata* are forwarded). + """ + FigureCanvasAgg.draw(self) + mpl.image.imsave( + filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper", + dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs) + + def print_png(self, filename_or_obj, *, metadata=None, pil_kwargs=None): + """ + Write the figure to a PNG file. + + Parameters + ---------- + filename_or_obj : str or path-like or file-like + The file to write to. + + metadata : dict, optional + Metadata in the PNG file as key-value pairs of bytes or latin-1 + encodable strings. + According to the PNG specification, keys must be shorter than 79 + chars. + + The `PNG specification`_ defines some common keywords that may be + used as appropriate: + + - Title: Short (one line) title or caption for image. + - Author: Name of image's creator. + - Description: Description of image (possibly long). + - Copyright: Copyright notice. + - Creation Time: Time of original image creation + (usually RFC 1123 format). + - Software: Software used to create the image. + - Disclaimer: Legal disclaimer. + - Warning: Warning of nature of content. + - Source: Device used to create the image. + - Comment: Miscellaneous comment; + conversion from other image format. + + Other keywords may be invented for other purposes. + + If 'Software' is not given, an autogenerated value for Matplotlib + will be used. This can be removed by setting it to *None*. + + For more details see the `PNG specification`_. + + .. _PNG specification: \ + https://www.w3.org/TR/2003/REC-PNG-20031110/#11keywords + + pil_kwargs : dict, optional + Keyword arguments passed to `PIL.Image.Image.save`. + + If the 'pnginfo' key is present, it completely overrides + *metadata*, including the default 'Software' key. + """ + self._print_pil(filename_or_obj, "png", pil_kwargs, metadata) + + def print_to_buffer(self): + FigureCanvasAgg.draw(self) + renderer = self.get_renderer() + return (bytes(renderer.buffer_rgba()), + (int(renderer.width), int(renderer.height))) + + # Note that these methods should typically be called via savefig() and + # print_figure(), and the latter ensures that `self.figure.dpi` already + # matches the dpi kwarg (if any). + + def print_jpg(self, filename_or_obj, *, metadata=None, pil_kwargs=None): + # savefig() has already applied savefig.facecolor; we now set it to + # white to make imsave() blend semi-transparent figures against an + # assumed white background. + with mpl.rc_context({"savefig.facecolor": "white"}): + self._print_pil(filename_or_obj, "jpeg", pil_kwargs, metadata) + + print_jpeg = print_jpg + + def print_tif(self, filename_or_obj, *, metadata=None, pil_kwargs=None): + self._print_pil(filename_or_obj, "tiff", pil_kwargs, metadata) + + print_tiff = print_tif + + def print_webp(self, filename_or_obj, *, metadata=None, pil_kwargs=None): + self._print_pil(filename_or_obj, "webp", pil_kwargs, metadata) + + print_jpg.__doc__, print_tif.__doc__, print_webp.__doc__ = map( + """ + Write the figure to a {} file. + + Parameters + ---------- + filename_or_obj : str or path-like or file-like + The file to write to. + pil_kwargs : dict, optional + Additional keyword arguments that are passed to + `PIL.Image.Image.save` when saving the figure. + """.format, ["JPEG", "TIFF", "WebP"]) + + +@_Backend.export +class _BackendAgg(_Backend): + backend_version = 'v2.2' + FigureCanvas = FigureCanvasAgg + FigureManager = FigureManagerBase diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_cairo.py b/lib/python3.12/site-packages/matplotlib/backends/backend_cairo.py new file mode 100644 index 0000000000000000000000000000000000000000..7409cd35b394344314ec14de768f77b80e512e8c --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_cairo.py @@ -0,0 +1,529 @@ +""" +A Cairo backend for Matplotlib +============================== +:Author: Steve Chaplin and others + +This backend depends on cairocffi or pycairo. +""" + +import functools +import gzip +import math + +import numpy as np + +try: + import cairo + if cairo.version_info < (1, 14, 0): # Introduced set_device_scale. + raise ImportError(f"Cairo backend requires cairo>=1.14.0, " + f"but only {cairo.version_info} is available") +except ImportError: + try: + import cairocffi as cairo + except ImportError as err: + raise ImportError( + "cairo backend requires that pycairo>=1.14.0 or cairocffi " + "is installed") from err + +from .. import _api, cbook, font_manager +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) +from matplotlib.font_manager import ttfFontProperty +from matplotlib.path import Path +from matplotlib.transforms import Affine2D + + +def _set_rgba(ctx, color, alpha, forced_alpha): + if len(color) == 3 or forced_alpha: + ctx.set_source_rgba(*color[:3], alpha) + else: + ctx.set_source_rgba(*color) + + +def _append_path(ctx, path, transform, clip=None): + for points, code in path.iter_segments( + transform, remove_nans=True, clip=clip): + if code == Path.MOVETO: + ctx.move_to(*points) + elif code == Path.CLOSEPOLY: + ctx.close_path() + elif code == Path.LINETO: + ctx.line_to(*points) + elif code == Path.CURVE3: + cur = np.asarray(ctx.get_current_point()) + a = points[:2] + b = points[-2:] + ctx.curve_to(*(cur / 3 + a * 2 / 3), *(a * 2 / 3 + b / 3), *b) + elif code == Path.CURVE4: + ctx.curve_to(*points) + + +def _cairo_font_args_from_font_prop(prop): + """ + Convert a `.FontProperties` or a `.FontEntry` to arguments that can be + passed to `.Context.select_font_face`. + """ + def attr(field): + try: + return getattr(prop, f"get_{field}")() + except AttributeError: + return getattr(prop, field) + + name = attr("name") + slant = getattr(cairo, f"FONT_SLANT_{attr('style').upper()}") + weight = attr("weight") + weight = (cairo.FONT_WEIGHT_NORMAL + if font_manager.weight_dict.get(weight, weight) < 550 + else cairo.FONT_WEIGHT_BOLD) + return name, slant, weight + + +class RendererCairo(RendererBase): + def __init__(self, dpi): + self.dpi = dpi + self.gc = GraphicsContextCairo(renderer=self) + self.width = None + self.height = None + self.text_ctx = cairo.Context( + cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)) + super().__init__() + + def set_context(self, ctx): + surface = ctx.get_target() + if hasattr(surface, "get_width") and hasattr(surface, "get_height"): + size = surface.get_width(), surface.get_height() + elif hasattr(surface, "get_extents"): # GTK4 RecordingSurface. + ext = surface.get_extents() + size = ext.width, ext.height + else: # vector surfaces. + ctx.save() + ctx.reset_clip() + rect, *rest = ctx.copy_clip_rectangle_list() + if rest: + raise TypeError("Cannot infer surface size") + _, _, *size = rect + ctx.restore() + self.gc.ctx = ctx + self.width, self.height = size + + @staticmethod + def _fill_and_stroke(ctx, fill_c, alpha, alpha_overrides): + if fill_c is not None: + ctx.save() + _set_rgba(ctx, fill_c, alpha, alpha_overrides) + ctx.fill_preserve() + ctx.restore() + ctx.stroke() + + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + ctx = gc.ctx + # Clip the path to the actual rendering extents if it isn't filled. + clip = (ctx.clip_extents() + if rgbFace is None and gc.get_hatch() is None + else None) + transform = (transform + + Affine2D().scale(1, -1).translate(0, self.height)) + ctx.new_path() + _append_path(ctx, path, transform, clip) + if rgbFace is not None: + ctx.save() + _set_rgba(ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha()) + ctx.fill_preserve() + ctx.restore() + hatch_path = gc.get_hatch_path() + if hatch_path: + dpi = int(self.dpi) + hatch_surface = ctx.get_target().create_similar( + cairo.Content.COLOR_ALPHA, dpi, dpi) + hatch_ctx = cairo.Context(hatch_surface) + _append_path(hatch_ctx, hatch_path, + Affine2D().scale(dpi, -dpi).translate(0, dpi), + None) + hatch_ctx.set_line_width(self.points_to_pixels(gc.get_hatch_linewidth())) + hatch_ctx.set_source_rgba(*gc.get_hatch_color()) + hatch_ctx.fill_preserve() + hatch_ctx.stroke() + hatch_pattern = cairo.SurfacePattern(hatch_surface) + hatch_pattern.set_extend(cairo.Extend.REPEAT) + ctx.save() + ctx.set_source(hatch_pattern) + ctx.fill_preserve() + ctx.restore() + ctx.stroke() + + def draw_markers(self, gc, marker_path, marker_trans, path, transform, + rgbFace=None): + # docstring inherited + + ctx = gc.ctx + ctx.new_path() + # Create the path for the marker; it needs to be flipped here already! + _append_path(ctx, marker_path, marker_trans + Affine2D().scale(1, -1)) + marker_path = ctx.copy_path_flat() + + # Figure out whether the path has a fill + x1, y1, x2, y2 = ctx.fill_extents() + if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0: + filled = False + # No fill, just unset this (so we don't try to fill it later on) + rgbFace = None + else: + filled = True + + transform = (transform + + Affine2D().scale(1, -1).translate(0, self.height)) + + ctx.new_path() + for i, (vertices, codes) in enumerate( + path.iter_segments(transform, simplify=False)): + if len(vertices): + x, y = vertices[-2:] + ctx.save() + + # Translate and apply path + ctx.translate(x, y) + ctx.append_path(marker_path) + + ctx.restore() + + # Slower code path if there is a fill; we need to draw + # the fill and stroke for each marker at the same time. + # Also flush out the drawing every once in a while to + # prevent the paths from getting way too long. + if filled or i % 1000 == 0: + self._fill_and_stroke( + ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha()) + + # Fast path, if there is no fill, draw everything in one step + if not filled: + self._fill_and_stroke( + ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha()) + + def draw_image(self, gc, x, y, im): + im = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(im[::-1]) + surface = cairo.ImageSurface.create_for_data( + im.ravel().data, cairo.FORMAT_ARGB32, + im.shape[1], im.shape[0], im.shape[1] * 4) + ctx = gc.ctx + y = self.height - y - im.shape[0] + + ctx.save() + ctx.set_source_surface(surface, float(x), float(y)) + ctx.paint() + ctx.restore() + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + + # Note: (x, y) are device/display coords, not user-coords, unlike other + # draw_* methods + if ismath: + self._draw_mathtext(gc, x, y, s, prop, angle) + + else: + ctx = gc.ctx + ctx.new_path() + ctx.move_to(x, y) + + ctx.save() + ctx.select_font_face(*_cairo_font_args_from_font_prop(prop)) + ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points())) + opts = cairo.FontOptions() + opts.set_antialias(gc.get_antialiased()) + ctx.set_font_options(opts) + if angle: + ctx.rotate(np.deg2rad(-angle)) + ctx.show_text(s) + ctx.restore() + + def _draw_mathtext(self, gc, x, y, s, prop, angle): + ctx = gc.ctx + width, height, descent, glyphs, rects = \ + self._text2path.mathtext_parser.parse(s, self.dpi, prop) + + ctx.save() + ctx.translate(x, y) + if angle: + ctx.rotate(np.deg2rad(-angle)) + + for font, fontsize, idx, ox, oy in glyphs: + ctx.new_path() + ctx.move_to(ox, -oy) + ctx.select_font_face( + *_cairo_font_args_from_font_prop(ttfFontProperty(font))) + ctx.set_font_size(self.points_to_pixels(fontsize)) + ctx.show_text(chr(idx)) + + for ox, oy, w, h in rects: + ctx.new_path() + ctx.rectangle(ox, -oy, w, -h) + ctx.set_source_rgb(0, 0, 0) + ctx.fill_preserve() + + ctx.restore() + + def get_canvas_width_height(self): + # docstring inherited + return self.width, self.height + + def get_text_width_height_descent(self, s, prop, ismath): + # docstring inherited + + if ismath == 'TeX': + return super().get_text_width_height_descent(s, prop, ismath) + + if ismath: + width, height, descent, *_ = \ + self._text2path.mathtext_parser.parse(s, self.dpi, prop) + return width, height, descent + + ctx = self.text_ctx + # problem - scale remembers last setting and font can become + # enormous causing program to crash + # save/restore prevents the problem + ctx.save() + ctx.select_font_face(*_cairo_font_args_from_font_prop(prop)) + ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points())) + + y_bearing, w, h = ctx.text_extents(s)[1:4] + ctx.restore() + + return w, h, h + y_bearing + + def new_gc(self): + # docstring inherited + self.gc.ctx.save() + # FIXME: The following doesn't properly implement a stack-like behavior + # and relies instead on the (non-guaranteed) fact that artists never + # rely on nesting gc states, so directly resetting the attributes (IOW + # a single-level stack) is enough. + self.gc._alpha = 1 + self.gc._forced_alpha = False # if True, _alpha overrides A from RGBA + self.gc._hatch = None + return self.gc + + def points_to_pixels(self, points): + # docstring inherited + return points / 72 * self.dpi + + +class GraphicsContextCairo(GraphicsContextBase): + _joind = { + 'bevel': cairo.LINE_JOIN_BEVEL, + 'miter': cairo.LINE_JOIN_MITER, + 'round': cairo.LINE_JOIN_ROUND, + } + + _capd = { + 'butt': cairo.LINE_CAP_BUTT, + 'projecting': cairo.LINE_CAP_SQUARE, + 'round': cairo.LINE_CAP_ROUND, + } + + def __init__(self, renderer): + super().__init__() + self.renderer = renderer + + def restore(self): + self.ctx.restore() + + def set_alpha(self, alpha): + super().set_alpha(alpha) + _set_rgba( + self.ctx, self._rgb, self.get_alpha(), self.get_forced_alpha()) + + def set_antialiased(self, b): + self.ctx.set_antialias( + cairo.ANTIALIAS_DEFAULT if b else cairo.ANTIALIAS_NONE) + + def get_antialiased(self): + return self.ctx.get_antialias() + + def set_capstyle(self, cs): + self.ctx.set_line_cap(_api.check_getitem(self._capd, capstyle=cs)) + self._capstyle = cs + + def set_clip_rectangle(self, rectangle): + if not rectangle: + return + x, y, w, h = np.round(rectangle.bounds) + ctx = self.ctx + ctx.new_path() + ctx.rectangle(x, self.renderer.height - h - y, w, h) + ctx.clip() + + def set_clip_path(self, path): + if not path: + return + tpath, affine = path.get_transformed_path_and_affine() + ctx = self.ctx + ctx.new_path() + affine = (affine + + Affine2D().scale(1, -1).translate(0, self.renderer.height)) + _append_path(ctx, tpath, affine) + ctx.clip() + + def set_dashes(self, offset, dashes): + self._dashes = offset, dashes + if dashes is None: + self.ctx.set_dash([], 0) # switch dashes off + else: + self.ctx.set_dash( + list(self.renderer.points_to_pixels(np.asarray(dashes))), + offset) + + def set_foreground(self, fg, isRGBA=None): + super().set_foreground(fg, isRGBA) + if len(self._rgb) == 3: + self.ctx.set_source_rgb(*self._rgb) + else: + self.ctx.set_source_rgba(*self._rgb) + + def get_rgb(self): + return self.ctx.get_source().get_rgba()[:3] + + def set_joinstyle(self, js): + self.ctx.set_line_join(_api.check_getitem(self._joind, joinstyle=js)) + self._joinstyle = js + + def set_linewidth(self, w): + self._linewidth = float(w) + self.ctx.set_line_width(self.renderer.points_to_pixels(w)) + + +class _CairoRegion: + def __init__(self, slices, data): + self._slices = slices + self._data = data + + +class FigureCanvasCairo(FigureCanvasBase): + @property + def _renderer(self): + # In theory, _renderer should be set in __init__, but GUI canvas + # subclasses (FigureCanvasFooCairo) don't always interact well with + # multiple inheritance (FigureCanvasFoo inits but doesn't super-init + # FigureCanvasCairo), so initialize it in the getter instead. + if not hasattr(self, "_cached_renderer"): + self._cached_renderer = RendererCairo(self.figure.dpi) + return self._cached_renderer + + def get_renderer(self): + return self._renderer + + def copy_from_bbox(self, bbox): + surface = self._renderer.gc.ctx.get_target() + if not isinstance(surface, cairo.ImageSurface): + raise RuntimeError( + "copy_from_bbox only works when rendering to an ImageSurface") + sw = surface.get_width() + sh = surface.get_height() + x0 = math.ceil(bbox.x0) + x1 = math.floor(bbox.x1) + y0 = math.ceil(sh - bbox.y1) + y1 = math.floor(sh - bbox.y0) + if not (0 <= x0 and x1 <= sw and bbox.x0 <= bbox.x1 + and 0 <= y0 and y1 <= sh and bbox.y0 <= bbox.y1): + raise ValueError("Invalid bbox") + sls = slice(y0, y0 + max(y1 - y0, 0)), slice(x0, x0 + max(x1 - x0, 0)) + data = (np.frombuffer(surface.get_data(), np.uint32) + .reshape((sh, sw))[sls].copy()) + return _CairoRegion(sls, data) + + def restore_region(self, region): + surface = self._renderer.gc.ctx.get_target() + if not isinstance(surface, cairo.ImageSurface): + raise RuntimeError( + "restore_region only works when rendering to an ImageSurface") + surface.flush() + sw = surface.get_width() + sh = surface.get_height() + sly, slx = region._slices + (np.frombuffer(surface.get_data(), np.uint32) + .reshape((sh, sw))[sly, slx]) = region._data + surface.mark_dirty_rectangle( + slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start) + + def print_png(self, fobj): + self._get_printed_image_surface().write_to_png(fobj) + + def print_rgba(self, fobj): + width, height = self.get_width_height() + buf = self._get_printed_image_surface().get_data() + fobj.write(cbook._premultiplied_argb32_to_unmultiplied_rgba8888( + np.asarray(buf).reshape((width, height, 4)))) + + print_raw = print_rgba + + def _get_printed_image_surface(self): + self._renderer.dpi = self.figure.dpi + width, height = self.get_width_height() + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + self._renderer.set_context(cairo.Context(surface)) + self.figure.draw(self._renderer) + return surface + + def _save(self, fmt, fobj, *, orientation='portrait'): + # save PDF/PS/SVG + + dpi = 72 + self.figure.dpi = dpi + w_in, h_in = self.figure.get_size_inches() + width_in_points, height_in_points = w_in * dpi, h_in * dpi + + if orientation == 'landscape': + width_in_points, height_in_points = ( + height_in_points, width_in_points) + + if fmt == 'ps': + if not hasattr(cairo, 'PSSurface'): + raise RuntimeError('cairo has not been compiled with PS ' + 'support enabled') + surface = cairo.PSSurface(fobj, width_in_points, height_in_points) + elif fmt == 'pdf': + if not hasattr(cairo, 'PDFSurface'): + raise RuntimeError('cairo has not been compiled with PDF ' + 'support enabled') + surface = cairo.PDFSurface(fobj, width_in_points, height_in_points) + elif fmt in ('svg', 'svgz'): + if not hasattr(cairo, 'SVGSurface'): + raise RuntimeError('cairo has not been compiled with SVG ' + 'support enabled') + if fmt == 'svgz': + if isinstance(fobj, str): + fobj = gzip.GzipFile(fobj, 'wb') + else: + fobj = gzip.GzipFile(None, 'wb', fileobj=fobj) + surface = cairo.SVGSurface(fobj, width_in_points, height_in_points) + else: + raise ValueError(f"Unknown format: {fmt!r}") + + self._renderer.dpi = self.figure.dpi + self._renderer.set_context(cairo.Context(surface)) + ctx = self._renderer.gc.ctx + + if orientation == 'landscape': + ctx.rotate(np.pi / 2) + ctx.translate(0, -height_in_points) + # Perhaps add an '%%Orientation: Landscape' comment? + + self.figure.draw(self._renderer) + + ctx.show_page() + surface.finish() + if fmt == 'svgz': + fobj.close() + + print_pdf = functools.partialmethod(_save, "pdf") + print_ps = functools.partialmethod(_save, "ps") + print_svg = functools.partialmethod(_save, "svg") + print_svgz = functools.partialmethod(_save, "svgz") + + +@_Backend.export +class _BackendCairo(_Backend): + backend_version = cairo.version + FigureCanvas = FigureCanvasCairo + FigureManager = FigureManagerBase diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4.py b/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4.py new file mode 100644 index 0000000000000000000000000000000000000000..620c9e5b94b664a54ae5e36e78f7398c4a19017c --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4.py @@ -0,0 +1,627 @@ +import functools +import io +import os + +import matplotlib as mpl +from matplotlib import _api, backend_tools, cbook +from matplotlib.backend_bases import ( + ToolContainerBase, MouseButton, + KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent) + +try: + import gi +except ImportError as err: + raise ImportError("The GTK4 backends require PyGObject") from err + +try: + # :raises ValueError: If module/version is already loaded, already + # required, or unavailable. + gi.require_version("Gtk", "4.0") +except ValueError as e: + # in this case we want to re-raise as ImportError so the + # auto-backend selection logic correctly skips. + raise ImportError(e) from e + +from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf +from . import _backend_gtk +from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 + _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, + TimerGTK as TimerGTK4, +) + +_GOBJECT_GE_3_47 = gi.version_info >= (3, 47, 0) + + +class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea): + required_interactive_framework = "gtk4" + supports_blit = False + manager_class = _api.classproperty(lambda cls: FigureManagerGTK4) + + def __init__(self, figure=None): + super().__init__(figure=figure) + + self.set_hexpand(True) + self.set_vexpand(True) + + self._idle_draw_id = 0 + self._rubberband_rect = None + + self.set_draw_func(self._draw_func) + self.connect('resize', self.resize_event) + self.connect('notify::scale-factor', self._update_device_pixel_ratio) + + click = Gtk.GestureClick() + click.set_button(0) # All buttons. + click.connect('pressed', self.button_press_event) + click.connect('released', self.button_release_event) + self.add_controller(click) + + key = Gtk.EventControllerKey() + key.connect('key-pressed', self.key_press_event) + key.connect('key-released', self.key_release_event) + self.add_controller(key) + + motion = Gtk.EventControllerMotion() + motion.connect('motion', self.motion_notify_event) + motion.connect('enter', self.enter_notify_event) + motion.connect('leave', self.leave_notify_event) + self.add_controller(motion) + + scroll = Gtk.EventControllerScroll.new( + Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect('scroll', self.scroll_event) + self.add_controller(scroll) + + self.set_focusable(True) + + css = Gtk.CssProvider() + style = '.matplotlib-canvas { background-color: white; }' + if Gtk.check_version(4, 9, 3) is None: + css.load_from_data(style, -1) + else: + css.load_from_data(style.encode('utf-8')) + style_ctx = self.get_style_context() + style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + style_ctx.add_class("matplotlib-canvas") + + def destroy(self): + CloseEvent("close_event", self)._process() + + def set_cursor(self, cursor): + # docstring inherited + self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor)) + + def _mpl_coords(self, xy=None): + """ + Convert the *xy* position of a GTK event, or of the current cursor + position if *xy* is None, to Matplotlib coordinates. + + GTK use logical pixels, but the figure is scaled to physical pixels for + rendering. Transform to physical pixels so that all of the down-stream + transforms work as expected. + + Also, the origin is different and needs to be corrected. + """ + if xy is None: + surface = self.get_native().get_surface() + is_over, x, y, mask = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + else: + x, y = xy + x = x * self.device_pixel_ratio + # flip y so y=0 is bottom of canvas + y = self.figure.bbox.height - y * self.device_pixel_ratio + return x, y + + def scroll_event(self, controller, dx, dy): + MouseEvent( + "scroll_event", self, *self._mpl_coords(), step=dy, + modifiers=self._mpl_modifiers(controller), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + return True + + def button_press_event(self, controller, n_press, x, y): + MouseEvent( + "button_press_event", self, *self._mpl_coords((x, y)), + controller.get_current_button(), + modifiers=self._mpl_modifiers(controller), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + self.grab_focus() + + def button_release_event(self, controller, n_press, x, y): + MouseEvent( + "button_release_event", self, *self._mpl_coords((x, y)), + controller.get_current_button(), + modifiers=self._mpl_modifiers(controller), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + + def key_press_event(self, controller, keyval, keycode, state): + KeyEvent( + "key_press_event", self, self._get_key(keyval, keycode, state), + *self._mpl_coords(), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + return True + + def key_release_event(self, controller, keyval, keycode, state): + KeyEvent( + "key_release_event", self, self._get_key(keyval, keycode, state), + *self._mpl_coords(), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + return True + + def motion_notify_event(self, controller, x, y): + MouseEvent( + "motion_notify_event", self, *self._mpl_coords((x, y)), + buttons=self._mpl_buttons(controller), + modifiers=self._mpl_modifiers(controller), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + + def enter_notify_event(self, controller, x, y): + LocationEvent( + "figure_enter_event", self, *self._mpl_coords((x, y)), + modifiers=self._mpl_modifiers(), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + + def leave_notify_event(self, controller): + LocationEvent( + "figure_leave_event", self, *self._mpl_coords(), + modifiers=self._mpl_modifiers(), + guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None, + )._process() + + def resize_event(self, area, width, height): + self._update_device_pixel_ratio() + dpi = self.figure.dpi + winch = width * self.device_pixel_ratio / dpi + hinch = height * self.device_pixel_ratio / dpi + self.figure.set_size_inches(winch, hinch, forward=False) + ResizeEvent("resize_event", self)._process() + self.draw_idle() + + def _mpl_buttons(self, controller): + # NOTE: This spews "Broken accounting of active state" warnings on + # right click on macOS. + surface = self.get_native().get_surface() + is_over, x, y, event_state = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + # NOTE: alternatively we could use + # event_state = controller.get_current_event_state() + # but for button_press/button_release this would report the state + # *prior* to the event rather than after it; the above reports the + # state *after* it. + mod_table = [ + (MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK), + (MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK), + (MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK), + (MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK), + (MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK), + ] + return {name for name, mask in mod_table if event_state & mask} + + def _mpl_modifiers(self, controller=None): + if controller is None: + surface = self.get_native().get_surface() + is_over, x, y, event_state = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + else: + event_state = controller.get_current_event_state() + mod_table = [ + ("ctrl", Gdk.ModifierType.CONTROL_MASK), + ("alt", Gdk.ModifierType.ALT_MASK), + ("shift", Gdk.ModifierType.SHIFT_MASK), + ("super", Gdk.ModifierType.SUPER_MASK), + ] + return [name for name, mask in mod_table if event_state & mask] + + def _get_key(self, keyval, keycode, state): + unikey = chr(Gdk.keyval_to_unicode(keyval)) + key = cbook._unikey_or_keysym_to_mplkey( + unikey, + Gdk.keyval_name(keyval)) + modifiers = [ + ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"), + ("alt", Gdk.ModifierType.ALT_MASK, "alt"), + ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"), + ("super", Gdk.ModifierType.SUPER_MASK, "super"), + ] + mods = [ + mod for mod, mask, mod_key in modifiers + if (mod_key != key and state & mask + and not (mod == "shift" and unikey.isprintable()))] + return "+".join([*mods, key]) + + def _update_device_pixel_ratio(self, *args, **kwargs): + # We need to be careful in cases with mixed resolution displays if + # device_pixel_ratio changes. + if self._set_device_pixel_ratio(self.get_scale_factor()): + self.draw() + + def _draw_rubberband(self, rect): + self._rubberband_rect = rect + # TODO: Only update the rubberband area. + self.queue_draw() + + def _draw_func(self, drawing_area, ctx, width, height): + self.on_draw_event(self, ctx) + self._post_draw(self, ctx) + + def _post_draw(self, widget, ctx): + if self._rubberband_rect is None: + return + + lw = 1 + dash = 3 + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) + x1 = x0 + w + y1 = y0 + h + + # Draw the lines from x0, y0 towards x1, y1 so that the + # dashes don't "jump" when moving the zoom box. + ctx.move_to(x0, y0) + ctx.line_to(x0, y1) + ctx.move_to(x0, y0) + ctx.line_to(x1, y0) + ctx.move_to(x0, y1) + ctx.line_to(x1, y1) + ctx.move_to(x1, y0) + ctx.line_to(x1, y1) + + ctx.set_antialias(1) + ctx.set_line_width(lw) + ctx.set_dash((dash, dash), 0) + ctx.set_source_rgb(0, 0, 0) + ctx.stroke_preserve() + + ctx.set_dash((dash, dash), dash) + ctx.set_source_rgb(1, 1, 1) + ctx.stroke() + + def on_draw_event(self, widget, ctx): + # to be overwritten by GTK4Agg or GTK4Cairo + pass + + def draw(self): + # docstring inherited + if self.is_drawable(): + self.queue_draw() + + def draw_idle(self): + # docstring inherited + if self._idle_draw_id != 0: + return + def idle_draw(*args): + try: + self.draw() + finally: + self._idle_draw_id = 0 + return False + self._idle_draw_id = GLib.idle_add(idle_draw) + + def flush_events(self): + # docstring inherited + context = GLib.MainContext.default() + while context.pending(): + context.iteration(True) + + +class NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box): + def __init__(self, canvas): + Gtk.Box.__init__(self) + + self.add_css_class('toolbar') + + self._gtk_ids = {} + for text, tooltip_text, image_file, callback in self.toolitems: + if text is None: + self.append(Gtk.Separator()) + continue + image = Gtk.Image.new_from_gicon( + Gio.Icon.new_for_string( + str(cbook._get_data_path('images', + f'{image_file}-symbolic.svg')))) + self._gtk_ids[text] = button = ( + Gtk.ToggleButton() if callback in ['zoom', 'pan'] else + Gtk.Button()) + button.set_child(image) + button.add_css_class('flat') + button.add_css_class('image-button') + # Save the handler id, so that we can block it as needed. + button._signal_handler = button.connect( + 'clicked', getattr(self, callback)) + button.set_tooltip_text(tooltip_text) + self.append(button) + + # This filler item ensures the toolbar is always at least two text + # lines high. Otherwise the canvas gets redrawn as the mouse hovers + # over images because those use two-line messages which resize the + # toolbar. + label = Gtk.Label() + label.set_markup( + '\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') + label.set_hexpand(True) # Push real message to the right. + self.append(label) + + self.message = Gtk.Label() + self.message.set_justify(Gtk.Justification.RIGHT) + self.append(self.message) + + _NavigationToolbar2GTK.__init__(self, canvas) + + def save_figure(self, *args): + dialog = Gtk.FileChooserNative( + title='Save the figure', + transient_for=self.canvas.get_root(), + action=Gtk.FileChooserAction.SAVE, + modal=True) + self._save_dialog = dialog # Must keep a reference. + + ff = Gtk.FileFilter() + ff.set_name('All files') + ff.add_pattern('*') + dialog.add_filter(ff) + dialog.set_filter(ff) + + formats = [] + default_format = None + for i, (name, fmts) in enumerate( + self.canvas.get_supported_filetypes_grouped().items()): + ff = Gtk.FileFilter() + ff.set_name(name) + for fmt in fmts: + ff.add_pattern(f'*.{fmt}') + dialog.add_filter(ff) + formats.append(name) + if self.canvas.get_default_filetype() in fmts: + default_format = i + # Setting the choice doesn't always work, so make sure the default + # format is first. + formats = [formats[default_format], *formats[:default_format], + *formats[default_format+1:]] + dialog.add_choice('format', 'File format', formats, formats) + dialog.set_choice('format', formats[0]) + + dialog.set_current_folder(Gio.File.new_for_path( + os.path.expanduser(mpl.rcParams['savefig.directory']))) + dialog.set_current_name(self.canvas.get_default_filename()) + + @functools.partial(dialog.connect, 'response') + def on_response(dialog, response): + file = dialog.get_file() + fmt = dialog.get_choice('format') + fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0] + dialog.destroy() + self._save_dialog = None + if response != Gtk.ResponseType.ACCEPT: + return + # Save dir for next time, unless empty str (which means use cwd). + if mpl.rcParams['savefig.directory']: + parent = file.get_parent() + mpl.rcParams['savefig.directory'] = parent.get_path() + try: + self.canvas.figure.savefig(file.get_path(), format=fmt) + except Exception as e: + msg = Gtk.MessageDialog( + transient_for=self.canvas.get_root(), + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, modal=True, + text=str(e)) + msg.show() + + dialog.show() + return self.UNKNOWN_SAVED_STATUS + + +class ToolbarGTK4(ToolContainerBase, Gtk.Box): + _icon_extension = '-symbolic.svg' + + def __init__(self, toolmanager): + ToolContainerBase.__init__(self, toolmanager) + Gtk.Box.__init__(self) + self.set_property('orientation', Gtk.Orientation.HORIZONTAL) + + # Tool items are created later, but must appear before the message. + self._tool_box = Gtk.Box() + self.append(self._tool_box) + self._groups = {} + self._toolitems = {} + + # This filler item ensures the toolbar is always at least two text + # lines high. Otherwise the canvas gets redrawn as the mouse hovers + # over images because those use two-line messages which resize the + # toolbar. + label = Gtk.Label() + label.set_markup( + '\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') + label.set_hexpand(True) # Push real message to the right. + self.append(label) + + self._message = Gtk.Label() + self._message.set_justify(Gtk.Justification.RIGHT) + self.append(self._message) + + def add_toolitem(self, name, group, position, image_file, description, + toggle): + if toggle: + button = Gtk.ToggleButton() + else: + button = Gtk.Button() + button.set_label(name) + button.add_css_class('flat') + + if image_file is not None: + image = Gtk.Image.new_from_gicon( + Gio.Icon.new_for_string(image_file)) + button.set_child(image) + button.add_css_class('image-button') + + if position is None: + position = -1 + + self._add_button(button, group, position) + signal = button.connect('clicked', self._call_tool, name) + button.set_tooltip_text(description) + self._toolitems.setdefault(name, []) + self._toolitems[name].append((button, signal)) + + def _find_child_at_position(self, group, position): + children = [None] + child = self._groups[group].get_first_child() + while child is not None: + children.append(child) + child = child.get_next_sibling() + return children[position] + + def _add_button(self, button, group, position): + if group not in self._groups: + if self._groups: + self._add_separator() + group_box = Gtk.Box() + self._tool_box.append(group_box) + self._groups[group] = group_box + self._groups[group].insert_child_after( + button, self._find_child_at_position(group, position)) + + def _call_tool(self, btn, name): + self.trigger_tool(name) + + def toggle_toolitem(self, name, toggled): + if name not in self._toolitems: + return + for toolitem, signal in self._toolitems[name]: + toolitem.handler_block(signal) + toolitem.set_active(toggled) + toolitem.handler_unblock(signal) + + def remove_toolitem(self, name): + for toolitem, _signal in self._toolitems.pop(name, []): + for group in self._groups: + if toolitem in self._groups[group]: + self._groups[group].remove(toolitem) + + def _add_separator(self): + sep = Gtk.Separator() + sep.set_property("orientation", Gtk.Orientation.VERTICAL) + self._tool_box.append(sep) + + def set_message(self, s): + self._message.set_label(s) + + +@backend_tools._register_tool_class(FigureCanvasGTK4) +class SaveFigureGTK4(backend_tools.SaveFigureBase): + def trigger(self, *args, **kwargs): + NavigationToolbar2GTK4.save_figure( + self._make_classic_style_pseudo_toolbar()) + + +@backend_tools._register_tool_class(FigureCanvasGTK4) +class HelpGTK4(backend_tools.ToolHelpBase): + def _normalize_shortcut(self, key): + """ + Convert Matplotlib key presses to GTK+ accelerator identifiers. + + Related to `FigureCanvasGTK4._get_key`. + """ + special = { + 'backspace': 'BackSpace', + 'pagedown': 'Page_Down', + 'pageup': 'Page_Up', + 'scroll_lock': 'Scroll_Lock', + } + + parts = key.split('+') + mods = ['<' + mod + '>' for mod in parts[:-1]] + key = parts[-1] + + if key in special: + key = special[key] + elif len(key) > 1: + key = key.capitalize() + elif key.isupper(): + mods += [''] + + return ''.join(mods) + key + + def _is_valid_shortcut(self, key): + """ + Check for a valid shortcut to be displayed. + + - GTK will never send 'cmd+' (see `FigureCanvasGTK4._get_key`). + - The shortcut window only shows keyboard shortcuts, not mouse buttons. + """ + return 'cmd+' not in key and not key.startswith('MouseButton.') + + def trigger(self, *args): + section = Gtk.ShortcutsSection() + + for name, tool in sorted(self.toolmanager.tools.items()): + if not tool.description: + continue + + # Putting everything in a separate group allows GTK to + # automatically split them into separate columns/pages, which is + # useful because we have lots of shortcuts, some with many keys + # that are very wide. + group = Gtk.ShortcutsGroup() + section.append(group) + # A hack to remove the title since we have no group naming. + child = group.get_first_child() + while child is not None: + child.set_visible(False) + child = child.get_next_sibling() + + shortcut = Gtk.ShortcutsShortcut( + accelerator=' '.join( + self._normalize_shortcut(key) + for key in self.toolmanager.get_tool_keymap(name) + if self._is_valid_shortcut(key)), + title=tool.name, + subtitle=tool.description) + group.append(shortcut) + + window = Gtk.ShortcutsWindow( + title='Help', + modal=True, + transient_for=self._figure.canvas.get_root()) + window.set_child(section) + + window.show() + + +@backend_tools._register_tool_class(FigureCanvasGTK4) +class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase): + def trigger(self, *args, **kwargs): + with io.BytesIO() as f: + self.canvas.print_rgba(f) + w, h = self.canvas.get_width_height() + pb = GdkPixbuf.Pixbuf.new_from_data(f.getbuffer(), + GdkPixbuf.Colorspace.RGB, True, + 8, w, h, w*4) + clipboard = self.canvas.get_clipboard() + clipboard.set(pb) + + +backend_tools._register_tool_class( + FigureCanvasGTK4, _backend_gtk.ConfigureSubplotsGTK) +backend_tools._register_tool_class( + FigureCanvasGTK4, _backend_gtk.RubberbandGTK) +Toolbar = ToolbarGTK4 + + +class FigureManagerGTK4(_FigureManagerGTK): + _toolbar2_class = NavigationToolbar2GTK4 + _toolmanager_toolbar_class = ToolbarGTK4 + + +@_BackendGTK.export +class _BackendGTK4(_BackendGTK): + FigureCanvas = FigureCanvasGTK4 + FigureManager = FigureManagerGTK4 diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4agg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4agg.py new file mode 100644 index 0000000000000000000000000000000000000000..0af07850a30aa1dac97fd24580e434e23b48bba5 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_gtk4agg.py @@ -0,0 +1,41 @@ +import numpy as np + +from .. import cbook +from . import backend_agg, backend_gtk4 +from .backend_gtk4 import GLib, Gtk, _BackendGTK4 + +import cairo # Presence of cairo is already checked by _backend_gtk. + + +class FigureCanvasGTK4Agg(backend_agg.FigureCanvasAgg, + backend_gtk4.FigureCanvasGTK4): + + def on_draw_event(self, widget, ctx): + if self._idle_draw_id: + GLib.source_remove(self._idle_draw_id) + self._idle_draw_id = 0 + self.draw() + + scale = self.device_pixel_ratio + allocation = self.get_allocation() + + Gtk.render_background( + self.get_style_context(), ctx, + allocation.x, allocation.y, + allocation.width, allocation.height) + + buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( + np.asarray(self.get_renderer().buffer_rgba())) + height, width, _ = buf.shape + image = cairo.ImageSurface.create_for_data( + buf.ravel().data, cairo.FORMAT_ARGB32, width, height) + image.set_device_scale(scale, scale) + ctx.set_source_surface(image, 0, 0) + ctx.paint() + + return False + + +@_BackendGTK4.export +class _BackendGTK4Agg(_BackendGTK4): + FigureCanvas = FigureCanvasGTK4Agg diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_nbagg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_nbagg.py new file mode 100644 index 0000000000000000000000000000000000000000..4d18e1e9fb88bd89cf5146d2c3f48bf3a54c9bca --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_nbagg.py @@ -0,0 +1,243 @@ +"""Interactive figures in the IPython notebook.""" +# Note: There is a notebook in +# lib/matplotlib/backends/web_backend/nbagg_uat.ipynb to help verify +# that changes made maintain expected behaviour. + +from base64 import b64encode +import io +import json +import pathlib +import uuid + +from ipykernel.comm import Comm +from IPython.display import display, Javascript, HTML + +from matplotlib import is_interactive +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import _Backend, CloseEvent, NavigationToolbar2 +from .backend_webagg_core import ( + FigureCanvasWebAggCore, FigureManagerWebAgg, NavigationToolbar2WebAgg) +from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611 + TimerTornado, TimerAsyncio) + + +def connection_info(): + """ + Return a string showing the figure and connection status for the backend. + + This is intended as a diagnostic tool, and not for general use. + """ + result = [ + '{fig} - {socket}'.format( + fig=(manager.canvas.figure.get_label() + or f"Figure {manager.num}"), + socket=manager.web_sockets) + for manager in Gcf.get_all_fig_managers() + ] + if not is_interactive(): + result.append(f'Figures pending show: {len(Gcf.figs)}') + return '\n'.join(result) + + +_FONT_AWESOME_CLASSES = { # font-awesome 4 names + 'home': 'fa fa-home', + 'back': 'fa fa-arrow-left', + 'forward': 'fa fa-arrow-right', + 'zoom_to_rect': 'fa fa-square-o', + 'move': 'fa fa-arrows', + 'download': 'fa fa-floppy-o', + None: None +} + + +class NavigationIPy(NavigationToolbar2WebAgg): + + # Use the standard toolbar items + download button + toolitems = [(text, tooltip_text, + _FONT_AWESOME_CLASSES[image_file], name_of_method) + for text, tooltip_text, image_file, name_of_method + in (NavigationToolbar2.toolitems + + (('Download', 'Download plot', 'download', 'download'),)) + if image_file in _FONT_AWESOME_CLASSES] + + +class FigureManagerNbAgg(FigureManagerWebAgg): + _toolbar2_class = ToolbarCls = NavigationIPy + + def __init__(self, canvas, num): + self._shown = False + super().__init__(canvas, num) + + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + canvas = canvas_class(figure) + manager = cls(canvas, num) + if is_interactive(): + manager.show() + canvas.draw_idle() + + def destroy(event): + canvas.mpl_disconnect(cid) + Gcf.destroy(manager) + + cid = canvas.mpl_connect('close_event', destroy) + return manager + + def display_js(self): + # XXX How to do this just once? It has to deal with multiple + # browser instances using the same kernel (require.js - but the + # file isn't static?). + display(Javascript(FigureManagerNbAgg.get_javascript())) + + def show(self): + if not self._shown: + self.display_js() + self._create_comm() + else: + self.canvas.draw_idle() + self._shown = True + # plt.figure adds an event which makes the figure in focus the active + # one. Disable this behaviour, as it results in figures being put as + # the active figure after they have been shown, even in non-interactive + # mode. + if hasattr(self, '_cidgcf'): + self.canvas.mpl_disconnect(self._cidgcf) + if not is_interactive(): + from matplotlib._pylab_helpers import Gcf + Gcf.figs.pop(self.num, None) + + def reshow(self): + """ + A special method to re-show the figure in the notebook. + + """ + self._shown = False + self.show() + + @property + def connected(self): + return bool(self.web_sockets) + + @classmethod + def get_javascript(cls, stream=None): + if stream is None: + output = io.StringIO() + else: + output = stream + super().get_javascript(stream=output) + output.write((pathlib.Path(__file__).parent + / "web_backend/js/nbagg_mpl.js") + .read_text(encoding="utf-8")) + if stream is None: + return output.getvalue() + + def _create_comm(self): + comm = CommSocket(self) + self.add_web_socket(comm) + return comm + + def destroy(self): + self._send_event('close') + # need to copy comms as callbacks will modify this list + for comm in list(self.web_sockets): + comm.on_close() + self.clearup_closed() + + def clearup_closed(self): + """Clear up any closed Comms.""" + self.web_sockets = {socket for socket in self.web_sockets + if socket.is_open()} + + if len(self.web_sockets) == 0: + CloseEvent("close_event", self.canvas)._process() + + def remove_comm(self, comm_id): + self.web_sockets = {socket for socket in self.web_sockets + if socket.comm.comm_id != comm_id} + + +class FigureCanvasNbAgg(FigureCanvasWebAggCore): + manager_class = FigureManagerNbAgg + + +class CommSocket: + """ + Manages the Comm connection between IPython and the browser (client). + + Comms are 2 way, with the CommSocket being able to publish a message + via the send_json method, and handle a message with on_message. On the + JS side figure.send_message and figure.ws.onmessage do the sending and + receiving respectively. + + """ + def __init__(self, manager): + self.supports_binary = None + self.manager = manager + self.uuid = str(uuid.uuid4()) + # Publish an output area with a unique ID. The javascript can then + # hook into this area. + display(HTML("
" % self.uuid)) + try: + self.comm = Comm('matplotlib', data={'id': self.uuid}) + except AttributeError as err: + raise RuntimeError('Unable to create an IPython notebook Comm ' + 'instance. Are you in the IPython ' + 'notebook?') from err + self.comm.on_msg(self.on_message) + + manager = self.manager + self._ext_close = False + + def _on_close(close_message): + self._ext_close = True + manager.remove_comm(close_message['content']['comm_id']) + manager.clearup_closed() + + self.comm.on_close(_on_close) + + def is_open(self): + return not (self._ext_close or self.comm._closed) + + def on_close(self): + # When the socket is closed, deregister the websocket with + # the FigureManager. + if self.is_open(): + try: + self.comm.close() + except KeyError: + # apparently already cleaned it up? + pass + + def send_json(self, content): + self.comm.send({'data': json.dumps(content)}) + + def send_binary(self, blob): + if self.supports_binary: + self.comm.send({'blob': 'image/png'}, buffers=[blob]) + else: + # The comm is ASCII, so we send the image in base64 encoded data + # URL form. + data = b64encode(blob).decode('ascii') + data_uri = f"data:image/png;base64,{data}" + self.comm.send({'data': data_uri}) + + def on_message(self, message): + # The 'supports_binary' message is relevant to the + # websocket itself. The other messages get passed along + # to matplotlib as-is. + + # Every message has a "type" and a "figure_id". + message = json.loads(message['content']['data']) + if message['type'] == 'closing': + self.on_close() + self.manager.clearup_closed() + elif message['type'] == 'supports_binary': + self.supports_binary = message['value'] + else: + self.manager.handle_json(message) + + +@_Backend.export +class _BackendNbAgg(_Backend): + FigureCanvas = FigureCanvasNbAgg + FigureManager = FigureManagerNbAgg diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_pdf.py b/lib/python3.12/site-packages/matplotlib/backends/backend_pdf.py new file mode 100644 index 0000000000000000000000000000000000000000..c1c5eb8819bef049516f6228530419faf9ff5b4f --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_pdf.py @@ -0,0 +1,2806 @@ +""" +A PDF Matplotlib backend. + +Author: Jouni K Seppänen and others. +""" + +import codecs +from datetime import timezone +from datetime import datetime +from enum import Enum +from functools import total_ordering +from io import BytesIO +import itertools +import logging +import math +import os +import string +import struct +import sys +import time +import types +import warnings +import zlib + +import numpy as np +from PIL import Image + +import matplotlib as mpl +from matplotlib import _api, _text_helpers, _type1font, cbook, dviread +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) +from matplotlib.backends.backend_mixed import MixedModeRenderer +from matplotlib.figure import Figure +from matplotlib.font_manager import get_font, fontManager as _fontManager +from matplotlib._afm import AFM +from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags +from matplotlib.transforms import Affine2D, BboxBase +from matplotlib.path import Path +from matplotlib.dates import UTC +from matplotlib import _path +from . import _backend_pdf_ps + +_log = logging.getLogger(__name__) + +# Overview +# +# The low-level knowledge about pdf syntax lies mainly in the pdfRepr +# function and the classes Reference, Name, Operator, and Stream. The +# PdfFile class knows about the overall structure of pdf documents. +# It provides a "write" method for writing arbitrary strings in the +# file, and an "output" method that passes objects through the pdfRepr +# function before writing them in the file. The output method is +# called by the RendererPdf class, which contains the various draw_foo +# methods. RendererPdf contains a GraphicsContextPdf instance, and +# each draw_foo calls self.check_gc before outputting commands. This +# method checks whether the pdf graphics state needs to be modified +# and outputs the necessary commands. GraphicsContextPdf represents +# the graphics state, and its "delta" method returns the commands that +# modify the state. + +# Add "pdf.use14corefonts: True" in your configuration file to use only +# the 14 PDF core fonts. These fonts do not need to be embedded; every +# PDF viewing application is required to have them. This results in very +# light PDF files you can use directly in LaTeX or ConTeXt documents +# generated with pdfTeX, without any conversion. + +# These fonts are: Helvetica, Helvetica-Bold, Helvetica-Oblique, +# Helvetica-BoldOblique, Courier, Courier-Bold, Courier-Oblique, +# Courier-BoldOblique, Times-Roman, Times-Bold, Times-Italic, +# Times-BoldItalic, Symbol, ZapfDingbats. +# +# Some tricky points: +# +# 1. The clip path can only be widened by popping from the state +# stack. Thus the state must be pushed onto the stack before narrowing +# the clip path. This is taken care of by GraphicsContextPdf. +# +# 2. Sometimes it is necessary to refer to something (e.g., font, +# image, or extended graphics state, which contains the alpha value) +# in the page stream by a name that needs to be defined outside the +# stream. PdfFile provides the methods fontName, imageObject, and +# alphaState for this purpose. The implementations of these methods +# should perhaps be generalized. + +# TODOs: +# +# * encoding of fonts, including mathtext fonts and Unicode support +# * TTF support has lots of small TODOs, e.g., how do you know if a font +# is serif/sans-serif, or symbolic/non-symbolic? +# * draw_quad_mesh + + +def _fill(strings, linelen=75): + """ + Make one string from sequence of strings, with whitespace in between. + + The whitespace is chosen to form lines of at most *linelen* characters, + if possible. + """ + currpos = 0 + lasti = 0 + result = [] + for i, s in enumerate(strings): + length = len(s) + if currpos + length < linelen: + currpos += length + 1 + else: + result.append(b' '.join(strings[lasti:i])) + lasti = i + currpos = length + result.append(b' '.join(strings[lasti:])) + return b'\n'.join(result) + + +def _create_pdf_info_dict(backend, metadata): + """ + Create a PDF infoDict based on user-supplied metadata. + + A default ``Creator``, ``Producer``, and ``CreationDate`` are added, though + the user metadata may override it. The date may be the current time, or a + time set by the ``SOURCE_DATE_EPOCH`` environment variable. + + Metadata is verified to have the correct keys and their expected types. Any + unknown keys/types will raise a warning. + + Parameters + ---------- + backend : str + The name of the backend to use in the Producer value. + + metadata : dict[str, Union[str, datetime, Name]] + A dictionary of metadata supplied by the user with information + following the PDF specification, also defined in + `~.backend_pdf.PdfPages` below. + + If any value is *None*, then the key will be removed. This can be used + to remove any pre-defined values. + + Returns + ------- + dict[str, Union[str, datetime, Name]] + A validated dictionary of metadata. + """ + + # get source date from SOURCE_DATE_EPOCH, if set + # See https://reproducible-builds.org/specs/source-date-epoch/ + source_date_epoch = os.getenv("SOURCE_DATE_EPOCH") + if source_date_epoch: + source_date = datetime.fromtimestamp(int(source_date_epoch), timezone.utc) + source_date = source_date.replace(tzinfo=UTC) + else: + source_date = datetime.today() + + info = { + 'Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org', + 'Producer': f'Matplotlib {backend} backend v{mpl.__version__}', + 'CreationDate': source_date, + **metadata + } + info = {k: v for (k, v) in info.items() if v is not None} + + def is_string_like(x): + return isinstance(x, str) + is_string_like.text_for_warning = "an instance of str" + + def is_date(x): + return isinstance(x, datetime) + is_date.text_for_warning = "an instance of datetime.datetime" + + def check_trapped(x): + if isinstance(x, Name): + return x.name in (b'True', b'False', b'Unknown') + else: + return x in ('True', 'False', 'Unknown') + check_trapped.text_for_warning = 'one of {"True", "False", "Unknown"}' + + keywords = { + 'Title': is_string_like, + 'Author': is_string_like, + 'Subject': is_string_like, + 'Keywords': is_string_like, + 'Creator': is_string_like, + 'Producer': is_string_like, + 'CreationDate': is_date, + 'ModDate': is_date, + 'Trapped': check_trapped, + } + for k in info: + if k not in keywords: + _api.warn_external(f'Unknown infodict keyword: {k!r}. ' + f'Must be one of {set(keywords)!r}.') + elif not keywords[k](info[k]): + _api.warn_external(f'Bad value for infodict keyword {k}. ' + f'Got {info[k]!r} which is not ' + f'{keywords[k].text_for_warning}.') + if 'Trapped' in info: + info['Trapped'] = Name(info['Trapped']) + + return info + + +def _datetime_to_pdf(d): + """ + Convert a datetime to a PDF string representing it. + + Used for PDF and PGF. + """ + r = d.strftime('D:%Y%m%d%H%M%S') + z = d.utcoffset() + if z is not None: + z = z.seconds + else: + if time.daylight: + z = time.altzone + else: + z = time.timezone + if z == 0: + r += 'Z' + elif z < 0: + r += "+%02d'%02d'" % ((-z) // 3600, (-z) % 3600) + else: + r += "-%02d'%02d'" % (z // 3600, z % 3600) + return r + + +def _calculate_quad_point_coordinates(x, y, width, height, angle=0): + """ + Calculate the coordinates of rectangle when rotated by angle around x, y + """ + + angle = math.radians(-angle) + sin_angle = math.sin(angle) + cos_angle = math.cos(angle) + a = x + height * sin_angle + b = y + height * cos_angle + c = x + width * cos_angle + height * sin_angle + d = y - width * sin_angle + height * cos_angle + e = x + width * cos_angle + f = y - width * sin_angle + return ((x, y), (e, f), (c, d), (a, b)) + + +def _get_coordinates_of_block(x, y, width, height, angle=0): + """ + Get the coordinates of rotated rectangle and rectangle that covers the + rotated rectangle. + """ + + vertices = _calculate_quad_point_coordinates(x, y, width, + height, angle) + + # Find min and max values for rectangle + # adjust so that QuadPoints is inside Rect + # PDF docs says that QuadPoints should be ignored if any point lies + # outside Rect, but for Acrobat it is enough that QuadPoints is on the + # border of Rect. + + pad = 0.00001 if angle % 90 else 0 + min_x = min(v[0] for v in vertices) - pad + min_y = min(v[1] for v in vertices) - pad + max_x = max(v[0] for v in vertices) + pad + max_y = max(v[1] for v in vertices) + pad + return (tuple(itertools.chain.from_iterable(vertices)), + (min_x, min_y, max_x, max_y)) + + +def _get_link_annotation(gc, x, y, width, height, angle=0): + """ + Create a link annotation object for embedding URLs. + """ + quadpoints, rect = _get_coordinates_of_block(x, y, width, height, angle) + link_annotation = { + 'Type': Name('Annot'), + 'Subtype': Name('Link'), + 'Rect': rect, + 'Border': [0, 0, 0], + 'A': { + 'S': Name('URI'), + 'URI': gc.get_url(), + }, + } + if angle % 90: + # Add QuadPoints + link_annotation['QuadPoints'] = quadpoints + return link_annotation + + +# PDF strings are supposed to be able to include any eight-bit data, except +# that unbalanced parens and backslashes must be escaped by a backslash. +# However, sf bug #2708559 shows that the carriage return character may get +# read as a newline; these characters correspond to \gamma and \Omega in TeX's +# math font encoding. Escaping them fixes the bug. +_str_escapes = str.maketrans({ + '\\': '\\\\', '(': '\\(', ')': '\\)', '\n': '\\n', '\r': '\\r'}) + + +def pdfRepr(obj): + """Map Python objects to PDF syntax.""" + + # Some objects defined later have their own pdfRepr method. + if hasattr(obj, 'pdfRepr'): + return obj.pdfRepr() + + # Floats. PDF does not have exponential notation (1.0e-10) so we + # need to use %f with some precision. Perhaps the precision + # should adapt to the magnitude of the number? + elif isinstance(obj, (float, np.floating)): + if not np.isfinite(obj): + raise ValueError("Can only output finite numbers in PDF") + r = b"%.10f" % obj + return r.rstrip(b'0').rstrip(b'.') + + # Booleans. Needs to be tested before integers since + # isinstance(True, int) is true. + elif isinstance(obj, bool): + return [b'false', b'true'][obj] + + # Integers are written as such. + elif isinstance(obj, (int, np.integer)): + return b"%d" % obj + + # Non-ASCII Unicode strings are encoded in UTF-16BE with byte-order mark. + elif isinstance(obj, str): + return pdfRepr(obj.encode('ascii') if obj.isascii() + else codecs.BOM_UTF16_BE + obj.encode('UTF-16BE')) + + # Strings are written in parentheses, with backslashes and parens + # escaped. Actually balanced parens are allowed, but it is + # simpler to escape them all. TODO: cut long strings into lines; + # I believe there is some maximum line length in PDF. + # Despite the extra decode/encode, translate is faster than regex. + elif isinstance(obj, bytes): + return ( + b'(' + + obj.decode('latin-1').translate(_str_escapes).encode('latin-1') + + b')') + + # Dictionaries. The keys must be PDF names, so if we find strings + # there, we make Name objects from them. The values may be + # anything, so the caller must ensure that PDF names are + # represented as Name objects. + elif isinstance(obj, dict): + return _fill([ + b"<<", + *[Name(k).pdfRepr() + b" " + pdfRepr(v) for k, v in obj.items()], + b">>", + ]) + + # Lists. + elif isinstance(obj, (list, tuple)): + return _fill([b"[", *[pdfRepr(val) for val in obj], b"]"]) + + # The null keyword. + elif obj is None: + return b'null' + + # A date. + elif isinstance(obj, datetime): + return pdfRepr(_datetime_to_pdf(obj)) + + # A bounding box + elif isinstance(obj, BboxBase): + return _fill([pdfRepr(val) for val in obj.bounds]) + + else: + raise TypeError(f"Don't know a PDF representation for {type(obj)} " + "objects") + + +def _font_supports_glyph(fonttype, glyph): + """ + Returns True if the font is able to provide codepoint *glyph* in a PDF. + + For a Type 3 font, this method returns True only for single-byte + characters. For Type 42 fonts this method return True if the character is + from the Basic Multilingual Plane. + """ + if fonttype == 3: + return glyph <= 255 + if fonttype == 42: + return glyph <= 65535 + raise NotImplementedError() + + +class Reference: + """ + PDF reference object. + + Use PdfFile.reserveObject() to create References. + """ + + def __init__(self, id): + self.id = id + + def __repr__(self): + return "" % self.id + + def pdfRepr(self): + return b"%d 0 R" % self.id + + def write(self, contents, file): + write = file.write + write(b"%d 0 obj\n" % self.id) + write(pdfRepr(contents)) + write(b"\nendobj\n") + + +@total_ordering +class Name: + """PDF name object.""" + __slots__ = ('name',) + _hexify = {c: '#%02x' % c + for c in {*range(256)} - {*range(ord('!'), ord('~') + 1)}} + + def __init__(self, name): + if isinstance(name, Name): + self.name = name.name + else: + if isinstance(name, bytes): + name = name.decode('ascii') + self.name = name.translate(self._hexify).encode('ascii') + + def __repr__(self): + return "" % self.name + + def __str__(self): + return '/' + self.name.decode('ascii') + + def __eq__(self, other): + return isinstance(other, Name) and self.name == other.name + + def __lt__(self, other): + return isinstance(other, Name) and self.name < other.name + + def __hash__(self): + return hash(self.name) + + def pdfRepr(self): + return b'/' + self.name + + +class Verbatim: + """Store verbatim PDF command content for later inclusion in the stream.""" + def __init__(self, x): + self._x = x + + def pdfRepr(self): + return self._x + + +class Op(Enum): + """PDF operators (not an exhaustive list).""" + + close_fill_stroke = b'b' + fill_stroke = b'B' + fill = b'f' + closepath = b'h' + close_stroke = b's' + stroke = b'S' + endpath = b'n' + begin_text = b'BT' + end_text = b'ET' + curveto = b'c' + rectangle = b're' + lineto = b'l' + moveto = b'm' + concat_matrix = b'cm' + use_xobject = b'Do' + setgray_stroke = b'G' + setgray_nonstroke = b'g' + setrgb_stroke = b'RG' + setrgb_nonstroke = b'rg' + setcolorspace_stroke = b'CS' + setcolorspace_nonstroke = b'cs' + setcolor_stroke = b'SCN' + setcolor_nonstroke = b'scn' + setdash = b'd' + setlinejoin = b'j' + setlinecap = b'J' + setgstate = b'gs' + gsave = b'q' + grestore = b'Q' + textpos = b'Td' + selectfont = b'Tf' + textmatrix = b'Tm' + show = b'Tj' + showkern = b'TJ' + setlinewidth = b'w' + clip = b'W' + shading = b'sh' + + def pdfRepr(self): + return self.value + + @classmethod + def paint_path(cls, fill, stroke): + """ + Return the PDF operator to paint a path. + + Parameters + ---------- + fill : bool + Fill the path with the fill color. + stroke : bool + Stroke the outline of the path with the line color. + """ + if stroke: + if fill: + return cls.fill_stroke + else: + return cls.stroke + else: + if fill: + return cls.fill + else: + return cls.endpath + + +class Stream: + """ + PDF stream object. + + This has no pdfRepr method. Instead, call begin(), then output the + contents of the stream by calling write(), and finally call end(). + """ + __slots__ = ('id', 'len', 'pdfFile', 'file', 'compressobj', 'extra', 'pos') + + def __init__(self, id, len, file, extra=None, png=None): + """ + Parameters + ---------- + id : int + Object id of the stream. + len : Reference or None + An unused Reference object for the length of the stream; + None means to use a memory buffer so the length can be inlined. + file : PdfFile + The underlying object to write the stream to. + extra : dict from Name to anything, or None + Extra key-value pairs to include in the stream header. + png : dict or None + If the data is already png encoded, the decode parameters. + """ + self.id = id # object id + self.len = len # id of length object + self.pdfFile = file + self.file = file.fh # file to which the stream is written + self.compressobj = None # compression object + if extra is None: + self.extra = dict() + else: + self.extra = extra.copy() + if png is not None: + self.extra.update({'Filter': Name('FlateDecode'), + 'DecodeParms': png}) + + self.pdfFile.recordXref(self.id) + if mpl.rcParams['pdf.compression'] and not png: + self.compressobj = zlib.compressobj( + mpl.rcParams['pdf.compression']) + if self.len is None: + self.file = BytesIO() + else: + self._writeHeader() + self.pos = self.file.tell() + + def _writeHeader(self): + write = self.file.write + write(b"%d 0 obj\n" % self.id) + dict = self.extra + dict['Length'] = self.len + if mpl.rcParams['pdf.compression']: + dict['Filter'] = Name('FlateDecode') + + write(pdfRepr(dict)) + write(b"\nstream\n") + + def end(self): + """Finalize stream.""" + + self._flush() + if self.len is None: + contents = self.file.getvalue() + self.len = len(contents) + self.file = self.pdfFile.fh + self._writeHeader() + self.file.write(contents) + self.file.write(b"\nendstream\nendobj\n") + else: + length = self.file.tell() - self.pos + self.file.write(b"\nendstream\nendobj\n") + self.pdfFile.writeObject(self.len, length) + + def write(self, data): + """Write some data on the stream.""" + + if self.compressobj is None: + self.file.write(data) + else: + compressed = self.compressobj.compress(data) + self.file.write(compressed) + + def _flush(self): + """Flush the compression object.""" + + if self.compressobj is not None: + compressed = self.compressobj.flush() + self.file.write(compressed) + self.compressobj = None + + +def _get_pdf_charprocs(font_path, glyph_ids): + font = get_font(font_path, hinting_factor=1) + conv = 1000 / font.units_per_EM # Conversion to PS units (1/1000's). + procs = {} + for glyph_id in glyph_ids: + g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + # NOTE: We should be using round(), but instead use + # "(x+.5).astype(int)" to keep backcompat with the old ttconv code + # (this is different for negative x's). + d1 = (np.array([g.horiAdvance, 0, *g.bbox]) * conv + .5).astype(int) + v, c = font.get_path() + v = (v * 64).astype(int) # Back to TrueType's internal units (1/64's). + # Backcompat with old ttconv code: control points between two quads are + # omitted if they are exactly at the midpoint between the control of + # the quad before and the quad after, but ttconv used to interpolate + # *after* conversion to PS units, causing floating point errors. Here + # we reproduce ttconv's logic, detecting these "implicit" points and + # re-interpolating them. Note that occasionally (e.g. with DejaVu Sans + # glyph "0") a point detected as "implicit" is actually explicit, and + # will thus be shifted by 1. + quads, = np.nonzero(c == 3) + quads_on = quads[1::2] + quads_mid_on = np.array( + sorted({*quads_on} & {*(quads - 1)} & {*(quads + 1)}), int) + implicit = quads_mid_on[ + (v[quads_mid_on] # As above, use astype(int), not // division + == ((v[quads_mid_on - 1] + v[quads_mid_on + 1]) / 2).astype(int)) + .all(axis=1)] + if (font.postscript_name, glyph_id) in [ + ("DejaVuSerif-Italic", 77), # j + ("DejaVuSerif-Italic", 135), # \AA + ]: + v[:, 0] -= 1 # Hard-coded backcompat (FreeType shifts glyph by 1). + v = (v * conv + .5).astype(int) # As above re: truncation vs rounding. + v[implicit] = (( # Fix implicit points; again, truncate. + (v[implicit - 1] + v[implicit + 1]) / 2).astype(int)) + procs[font.get_glyph_name(glyph_id)] = ( + " ".join(map(str, d1)).encode("ascii") + b" d1\n" + + _path.convert_to_string( + Path(v, c), None, None, False, None, -1, + # no code for quad Beziers triggers auto-conversion to cubics. + [b"m", b"l", b"", b"c", b"h"], True) + + b"f") + return procs + + +class PdfFile: + """PDF file object.""" + + def __init__(self, filename, metadata=None): + """ + Parameters + ---------- + filename : str or path-like or file-like + Output target; if a string, a file will be opened for writing. + + metadata : dict from strings to strings and dates + Information dictionary object (see PDF reference section 10.2.1 + 'Document Information Dictionary'), e.g.: + ``{'Creator': 'My software', 'Author': 'Me', 'Title': 'Awesome'}``. + + The standard keys are 'Title', 'Author', 'Subject', 'Keywords', + 'Creator', 'Producer', 'CreationDate', 'ModDate', and + 'Trapped'. Values have been predefined for 'Creator', 'Producer' + and 'CreationDate'. They can be removed by setting them to `None`. + """ + super().__init__() + + self._object_seq = itertools.count(1) # consumed by reserveObject + self.xrefTable = [[0, 65535, 'the zero object']] + self.passed_in_file_object = False + self.original_file_like = None + self.tell_base = 0 + fh, opened = cbook.to_filehandle(filename, "wb", return_opened=True) + if not opened: + try: + self.tell_base = filename.tell() + except OSError: + fh = BytesIO() + self.original_file_like = filename + else: + fh = filename + self.passed_in_file_object = True + + self.fh = fh + self.currentstream = None # stream object to write to, if any + fh.write(b"%PDF-1.4\n") # 1.4 is the first version to have alpha + # Output some eight-bit chars as a comment so various utilities + # recognize the file as binary by looking at the first few + # lines (see note in section 3.4.1 of the PDF reference). + fh.write(b"%\254\334 \253\272\n") + + self.rootObject = self.reserveObject('root') + self.pagesObject = self.reserveObject('pages') + self.pageList = [] + self.fontObject = self.reserveObject('fonts') + self._extGStateObject = self.reserveObject('extended graphics states') + self.hatchObject = self.reserveObject('tiling patterns') + self.gouraudObject = self.reserveObject('Gouraud triangles') + self.XObjectObject = self.reserveObject('external objects') + self.resourceObject = self.reserveObject('resources') + + root = {'Type': Name('Catalog'), + 'Pages': self.pagesObject} + self.writeObject(self.rootObject, root) + + self.infoDict = _create_pdf_info_dict('pdf', metadata or {}) + + self.fontNames = {} # maps filenames to internal font names + self._internal_font_seq = (Name(f'F{i}') for i in itertools.count(1)) + self.dviFontInfo = {} # maps dvi font names to embedding information + # differently encoded Type-1 fonts may share the same descriptor + self.type1Descriptors = {} + self._character_tracker = _backend_pdf_ps.CharacterTracker() + + self.alphaStates = {} # maps alpha values to graphics state objects + self._alpha_state_seq = (Name(f'A{i}') for i in itertools.count(1)) + self._soft_mask_states = {} + self._soft_mask_seq = (Name(f'SM{i}') for i in itertools.count(1)) + self._soft_mask_groups = [] + self._hatch_patterns = {} + self._hatch_pattern_seq = (Name(f'H{i}') for i in itertools.count(1)) + self.gouraudTriangles = [] + + self._images = {} + self._image_seq = (Name(f'I{i}') for i in itertools.count(1)) + + self.markers = {} + self.multi_byte_charprocs = {} + + self.paths = [] + + # A list of annotations for each page. Each entry is a tuple of the + # overall Annots object reference that's inserted into the page object, + # followed by a list of the actual annotations. + self._annotations = [] + # For annotations added before a page is created; mostly for the + # purpose of newTextnote. + self.pageAnnotations = [] + + # The PDF spec recommends to include every procset + procsets = [Name(x) for x in "PDF Text ImageB ImageC ImageI".split()] + + # Write resource dictionary. + # Possibly TODO: more general ExtGState (graphics state dictionaries) + # ColorSpace Pattern Shading Properties + resources = {'Font': self.fontObject, + 'XObject': self.XObjectObject, + 'ExtGState': self._extGStateObject, + 'Pattern': self.hatchObject, + 'Shading': self.gouraudObject, + 'ProcSet': procsets} + self.writeObject(self.resourceObject, resources) + + def newPage(self, width, height): + self.endStream() + + self.width, self.height = width, height + contentObject = self.reserveObject('page contents') + annotsObject = self.reserveObject('annotations') + thePage = {'Type': Name('Page'), + 'Parent': self.pagesObject, + 'Resources': self.resourceObject, + 'MediaBox': [0, 0, 72 * width, 72 * height], + 'Contents': contentObject, + 'Annots': annotsObject, + } + pageObject = self.reserveObject('page') + self.writeObject(pageObject, thePage) + self.pageList.append(pageObject) + self._annotations.append((annotsObject, self.pageAnnotations)) + + self.beginStream(contentObject.id, + self.reserveObject('length of content stream')) + # Initialize the pdf graphics state to match the default Matplotlib + # graphics context (colorspace and joinstyle). + self.output(Name('DeviceRGB'), Op.setcolorspace_stroke) + self.output(Name('DeviceRGB'), Op.setcolorspace_nonstroke) + self.output(GraphicsContextPdf.joinstyles['round'], Op.setlinejoin) + + # Clear the list of annotations for the next page + self.pageAnnotations = [] + + def newTextnote(self, text, positionRect=[-100, -100, 0, 0]): + # Create a new annotation of type text + theNote = {'Type': Name('Annot'), + 'Subtype': Name('Text'), + 'Contents': text, + 'Rect': positionRect, + } + self.pageAnnotations.append(theNote) + + def _get_subsetted_psname(self, ps_name, charmap): + def toStr(n, base): + if n < base: + return string.ascii_uppercase[n] + else: + return ( + toStr(n // base, base) + string.ascii_uppercase[n % base] + ) + + # encode to string using base 26 + hashed = hash(frozenset(charmap.keys())) % ((sys.maxsize + 1) * 2) + prefix = toStr(hashed, 26) + + # get first 6 characters from prefix + return prefix[:6] + "+" + ps_name + + def finalize(self): + """Write out the various deferred objects and the pdf end matter.""" + + self.endStream() + self._write_annotations() + self.writeFonts() + self.writeExtGSTates() + self._write_soft_mask_groups() + self.writeHatches() + self.writeGouraudTriangles() + xobjects = { + name: ob for image, name, ob in self._images.values()} + for tup in self.markers.values(): + xobjects[tup[0]] = tup[1] + for name, value in self.multi_byte_charprocs.items(): + xobjects[name] = value + for name, path, trans, ob, join, cap, padding, filled, stroked \ + in self.paths: + xobjects[name] = ob + self.writeObject(self.XObjectObject, xobjects) + self.writeImages() + self.writeMarkers() + self.writePathCollectionTemplates() + self.writeObject(self.pagesObject, + {'Type': Name('Pages'), + 'Kids': self.pageList, + 'Count': len(self.pageList)}) + self.writeInfoDict() + + # Finalize the file + self.writeXref() + self.writeTrailer() + + def close(self): + """Flush all buffers and free all resources.""" + + self.endStream() + if self.passed_in_file_object: + self.fh.flush() + else: + if self.original_file_like is not None: + self.original_file_like.write(self.fh.getvalue()) + self.fh.close() + + def write(self, data): + if self.currentstream is None: + self.fh.write(data) + else: + self.currentstream.write(data) + + def output(self, *data): + self.write(_fill([pdfRepr(x) for x in data])) + self.write(b'\n') + + def beginStream(self, id, len, extra=None, png=None): + assert self.currentstream is None + self.currentstream = Stream(id, len, self, extra, png) + + def endStream(self): + if self.currentstream is not None: + self.currentstream.end() + self.currentstream = None + + def outputStream(self, ref, data, *, extra=None): + self.beginStream(ref.id, None, extra) + self.currentstream.write(data) + self.endStream() + + def _write_annotations(self): + for annotsObject, annotations in self._annotations: + self.writeObject(annotsObject, annotations) + + def fontName(self, fontprop): + """ + Select a font based on fontprop and return a name suitable for + Op.selectfont. If fontprop is a string, it will be interpreted + as the filename of the font. + """ + + if isinstance(fontprop, str): + filenames = [fontprop] + elif mpl.rcParams['pdf.use14corefonts']: + filenames = _fontManager._find_fonts_by_props( + fontprop, fontext='afm', directory=RendererPdf._afm_font_dir + ) + else: + filenames = _fontManager._find_fonts_by_props(fontprop) + first_Fx = None + for fname in filenames: + Fx = self.fontNames.get(fname) + if not first_Fx: + first_Fx = Fx + if Fx is None: + Fx = next(self._internal_font_seq) + self.fontNames[fname] = Fx + _log.debug('Assigning font %s = %r', Fx, fname) + if not first_Fx: + first_Fx = Fx + + # find_fontsprop's first value always adheres to + # findfont's value, so technically no behaviour change + return first_Fx + + def dviFontName(self, dvifont): + """ + Given a dvi font object, return a name suitable for Op.selectfont. + This registers the font information in ``self.dviFontInfo`` if not yet + registered. + """ + + dvi_info = self.dviFontInfo.get(dvifont.texname) + if dvi_info is not None: + return dvi_info.pdfname + + tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) + psfont = tex_font_map[dvifont.texname] + if psfont.filename is None: + raise ValueError( + "No usable font file found for {} (TeX: {}); " + "the font may lack a Type-1 version" + .format(psfont.psname, dvifont.texname)) + + pdfname = next(self._internal_font_seq) + _log.debug('Assigning font %s = %s (dvi)', pdfname, dvifont.texname) + self.dviFontInfo[dvifont.texname] = types.SimpleNamespace( + dvifont=dvifont, + pdfname=pdfname, + fontfile=psfont.filename, + basefont=psfont.psname, + encodingfile=psfont.encoding, + effects=psfont.effects) + return pdfname + + def writeFonts(self): + fonts = {} + for dviname, info in sorted(self.dviFontInfo.items()): + Fx = info.pdfname + _log.debug('Embedding Type-1 font %s from dvi.', dviname) + fonts[Fx] = self._embedTeXFont(info) + for filename in sorted(self.fontNames): + Fx = self.fontNames[filename] + _log.debug('Embedding font %s.', filename) + if filename.endswith('.afm'): + # from pdf.use14corefonts + _log.debug('Writing AFM font.') + fonts[Fx] = self._write_afm_font(filename) + else: + # a normal TrueType font + _log.debug('Writing TrueType font.') + chars = self._character_tracker.used.get(filename) + if chars: + fonts[Fx] = self.embedTTF(filename, chars) + self.writeObject(self.fontObject, fonts) + + def _write_afm_font(self, filename): + with open(filename, 'rb') as fh: + font = AFM(fh) + fontname = font.get_fontname() + fontdict = {'Type': Name('Font'), + 'Subtype': Name('Type1'), + 'BaseFont': Name(fontname), + 'Encoding': Name('WinAnsiEncoding')} + fontdictObject = self.reserveObject('font dictionary') + self.writeObject(fontdictObject, fontdict) + return fontdictObject + + def _embedTeXFont(self, fontinfo): + _log.debug('Embedding TeX font %s - fontinfo=%s', + fontinfo.dvifont.texname, fontinfo.__dict__) + + # Widths + widthsObject = self.reserveObject('font widths') + self.writeObject(widthsObject, fontinfo.dvifont.widths) + + # Font dictionary + fontdictObject = self.reserveObject('font dictionary') + fontdict = { + 'Type': Name('Font'), + 'Subtype': Name('Type1'), + 'FirstChar': 0, + 'LastChar': len(fontinfo.dvifont.widths) - 1, + 'Widths': widthsObject, + } + + # Encoding (if needed) + if fontinfo.encodingfile is not None: + fontdict['Encoding'] = { + 'Type': Name('Encoding'), + 'Differences': [ + 0, *map(Name, dviread._parse_enc(fontinfo.encodingfile))], + } + + # If no file is specified, stop short + if fontinfo.fontfile is None: + _log.warning( + "Because of TeX configuration (pdftex.map, see updmap option " + "pdftexDownloadBase14) the font %s is not embedded. This is " + "deprecated as of PDF 1.5 and it may cause the consumer " + "application to show something that was not intended.", + fontinfo.basefont) + fontdict['BaseFont'] = Name(fontinfo.basefont) + self.writeObject(fontdictObject, fontdict) + return fontdictObject + + # We have a font file to embed - read it in and apply any effects + t1font = _type1font.Type1Font(fontinfo.fontfile) + if fontinfo.effects: + t1font = t1font.transform(fontinfo.effects) + fontdict['BaseFont'] = Name(t1font.prop['FontName']) + + # Font descriptors may be shared between differently encoded + # Type-1 fonts, so only create a new descriptor if there is no + # existing descriptor for this font. + effects = (fontinfo.effects.get('slant', 0.0), + fontinfo.effects.get('extend', 1.0)) + fontdesc = self.type1Descriptors.get((fontinfo.fontfile, effects)) + if fontdesc is None: + fontdesc = self.createType1Descriptor(t1font, fontinfo.fontfile) + self.type1Descriptors[(fontinfo.fontfile, effects)] = fontdesc + fontdict['FontDescriptor'] = fontdesc + + self.writeObject(fontdictObject, fontdict) + return fontdictObject + + def createType1Descriptor(self, t1font, fontfile): + # Create and write the font descriptor and the font file + # of a Type-1 font + fontdescObject = self.reserveObject('font descriptor') + fontfileObject = self.reserveObject('font file') + + italic_angle = t1font.prop['ItalicAngle'] + fixed_pitch = t1font.prop['isFixedPitch'] + + flags = 0 + # fixed width + if fixed_pitch: + flags |= 1 << 0 + # TODO: serif + if 0: + flags |= 1 << 1 + # TODO: symbolic (most TeX fonts are) + if 1: + flags |= 1 << 2 + # non-symbolic + else: + flags |= 1 << 5 + # italic + if italic_angle: + flags |= 1 << 6 + # TODO: all caps + if 0: + flags |= 1 << 16 + # TODO: small caps + if 0: + flags |= 1 << 17 + # TODO: force bold + if 0: + flags |= 1 << 18 + + ft2font = get_font(fontfile) + + descriptor = { + 'Type': Name('FontDescriptor'), + 'FontName': Name(t1font.prop['FontName']), + 'Flags': flags, + 'FontBBox': ft2font.bbox, + 'ItalicAngle': italic_angle, + 'Ascent': ft2font.ascender, + 'Descent': ft2font.descender, + 'CapHeight': 1000, # TODO: find this out + 'XHeight': 500, # TODO: this one too + 'FontFile': fontfileObject, + 'FontFamily': t1font.prop['FamilyName'], + 'StemV': 50, # TODO + # (see also revision 3874; but not all TeX distros have AFM files!) + # 'FontWeight': a number where 400 = Regular, 700 = Bold + } + + self.writeObject(fontdescObject, descriptor) + + self.outputStream(fontfileObject, b"".join(t1font.parts[:2]), + extra={'Length1': len(t1font.parts[0]), + 'Length2': len(t1font.parts[1]), + 'Length3': 0}) + + return fontdescObject + + def _get_xobject_glyph_name(self, filename, glyph_name): + Fx = self.fontName(filename) + return "-".join([ + Fx.name.decode(), + os.path.splitext(os.path.basename(filename))[0], + glyph_name]) + + _identityToUnicodeCMap = b"""/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo +<< /Registry (Adobe) + /Ordering (UCS) + /Supplement 0 +>> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<0000> +endcodespacerange +%d beginbfrange +%s +endbfrange +endcmap +CMapName currentdict /CMap defineresource pop +end +end""" + + def embedTTF(self, filename, characters): + """Embed the TTF font from the named file into the document.""" + + font = get_font(filename) + fonttype = mpl.rcParams['pdf.fonttype'] + + def cvt(length, upe=font.units_per_EM, nearest=True): + """Convert font coordinates to PDF glyph coordinates.""" + value = length / upe * 1000 + if nearest: + return round(value) + # Best(?) to round away from zero for bounding boxes and the like. + if value < 0: + return math.floor(value) + else: + return math.ceil(value) + + def embedTTFType3(font, characters, descriptor): + """The Type 3-specific part of embedding a Truetype font""" + widthsObject = self.reserveObject('font widths') + fontdescObject = self.reserveObject('font descriptor') + fontdictObject = self.reserveObject('font dictionary') + charprocsObject = self.reserveObject('character procs') + differencesArray = [] + firstchar, lastchar = 0, 255 + bbox = [cvt(x, nearest=False) for x in font.bbox] + + fontdict = { + 'Type': Name('Font'), + 'BaseFont': ps_name, + 'FirstChar': firstchar, + 'LastChar': lastchar, + 'FontDescriptor': fontdescObject, + 'Subtype': Name('Type3'), + 'Name': descriptor['FontName'], + 'FontBBox': bbox, + 'FontMatrix': [.001, 0, 0, .001, 0, 0], + 'CharProcs': charprocsObject, + 'Encoding': { + 'Type': Name('Encoding'), + 'Differences': differencesArray}, + 'Widths': widthsObject + } + + from encodings import cp1252 + + # Make the "Widths" array + def get_char_width(charcode): + s = ord(cp1252.decoding_table[charcode]) + width = font.load_char( + s, flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING).horiAdvance + return cvt(width) + with warnings.catch_warnings(): + # Ignore 'Required glyph missing from current font' warning + # from ft2font: here we're just building the widths table, but + # the missing glyphs may not even be used in the actual string. + warnings.filterwarnings("ignore") + widths = [get_char_width(charcode) + for charcode in range(firstchar, lastchar+1)] + descriptor['MaxWidth'] = max(widths) + + # Make the "Differences" array, sort the ccodes < 255 from + # the multi-byte ccodes, and build the whole set of glyph ids + # that we need from this font. + glyph_ids = [] + differences = [] + multi_byte_chars = set() + for c in characters: + ccode = c + gind = font.get_char_index(ccode) + glyph_ids.append(gind) + glyph_name = font.get_glyph_name(gind) + if ccode <= 255: + differences.append((ccode, glyph_name)) + else: + multi_byte_chars.add(glyph_name) + differences.sort() + + last_c = -2 + for c, name in differences: + if c != last_c + 1: + differencesArray.append(c) + differencesArray.append(Name(name)) + last_c = c + + # Make the charprocs array. + rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + charprocs = {} + for charname in sorted(rawcharprocs): + stream = rawcharprocs[charname] + charprocDict = {} + # The 2-byte characters are used as XObjects, so they + # need extra info in their dictionary + if charname in multi_byte_chars: + charprocDict = {'Type': Name('XObject'), + 'Subtype': Name('Form'), + 'BBox': bbox} + # Each glyph includes bounding box information, + # but xpdf and ghostscript can't handle it in a + # Form XObject (they segfault!!!), so we remove it + # from the stream here. It's not needed anyway, + # since the Form XObject includes it in its BBox + # value. + stream = stream[stream.find(b"d1") + 2:] + charprocObject = self.reserveObject('charProc') + self.outputStream(charprocObject, stream, extra=charprocDict) + + # Send the glyphs with ccode > 255 to the XObject dictionary, + # and the others to the font itself + if charname in multi_byte_chars: + name = self._get_xobject_glyph_name(filename, charname) + self.multi_byte_charprocs[name] = charprocObject + else: + charprocs[charname] = charprocObject + + # Write everything out + self.writeObject(fontdictObject, fontdict) + self.writeObject(fontdescObject, descriptor) + self.writeObject(widthsObject, widths) + self.writeObject(charprocsObject, charprocs) + + return fontdictObject + + def embedTTFType42(font, characters, descriptor): + """The Type 42-specific part of embedding a Truetype font""" + fontdescObject = self.reserveObject('font descriptor') + cidFontDictObject = self.reserveObject('CID font dictionary') + type0FontDictObject = self.reserveObject('Type 0 font dictionary') + cidToGidMapObject = self.reserveObject('CIDToGIDMap stream') + fontfileObject = self.reserveObject('font file stream') + wObject = self.reserveObject('Type 0 widths') + toUnicodeMapObject = self.reserveObject('ToUnicode map') + + subset_str = "".join(chr(c) for c in characters) + _log.debug("SUBSET %s characters: %s", filename, subset_str) + with _backend_pdf_ps.get_glyphs_subset(filename, subset_str) as subset: + fontdata = _backend_pdf_ps.font_as_file(subset) + _log.debug( + "SUBSET %s %d -> %d", filename, + os.stat(filename).st_size, fontdata.getbuffer().nbytes + ) + + # We need this ref for XObjects + full_font = font + + # reload the font object from the subset + # (all the necessary data could probably be obtained directly + # using fontLib.ttLib) + font = FT2Font(fontdata) + + cidFontDict = { + 'Type': Name('Font'), + 'Subtype': Name('CIDFontType2'), + 'BaseFont': ps_name, + 'CIDSystemInfo': { + 'Registry': 'Adobe', + 'Ordering': 'Identity', + 'Supplement': 0}, + 'FontDescriptor': fontdescObject, + 'W': wObject, + 'CIDToGIDMap': cidToGidMapObject + } + + type0FontDict = { + 'Type': Name('Font'), + 'Subtype': Name('Type0'), + 'BaseFont': ps_name, + 'Encoding': Name('Identity-H'), + 'DescendantFonts': [cidFontDictObject], + 'ToUnicode': toUnicodeMapObject + } + + # Make fontfile stream + descriptor['FontFile2'] = fontfileObject + self.outputStream( + fontfileObject, fontdata.getvalue(), + extra={'Length1': fontdata.getbuffer().nbytes}) + + # Make the 'W' (Widths) array, CidToGidMap and ToUnicode CMap + # at the same time + cid_to_gid_map = ['\0'] * 65536 + widths = [] + max_ccode = 0 + for c in characters: + ccode = c + gind = font.get_char_index(ccode) + glyph = font.load_char(ccode, + flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) + widths.append((ccode, cvt(glyph.horiAdvance))) + if ccode < 65536: + cid_to_gid_map[ccode] = chr(gind) + max_ccode = max(ccode, max_ccode) + widths.sort() + cid_to_gid_map = cid_to_gid_map[:max_ccode + 1] + + last_ccode = -2 + w = [] + max_width = 0 + unicode_groups = [] + for ccode, width in widths: + if ccode != last_ccode + 1: + w.append(ccode) + w.append([width]) + unicode_groups.append([ccode, ccode]) + else: + w[-1].append(width) + unicode_groups[-1][1] = ccode + max_width = max(max_width, width) + last_ccode = ccode + + unicode_bfrange = [] + for start, end in unicode_groups: + # Ensure the CID map contains only chars from BMP + if start > 65535: + continue + end = min(65535, end) + + unicode_bfrange.append( + b"<%04x> <%04x> [%s]" % + (start, end, + b" ".join(b"<%04x>" % x for x in range(start, end+1)))) + unicode_cmap = (self._identityToUnicodeCMap % + (len(unicode_groups), b"\n".join(unicode_bfrange))) + + # Add XObjects for unsupported chars + glyph_ids = [] + for ccode in characters: + if not _font_supports_glyph(fonttype, ccode): + gind = full_font.get_char_index(ccode) + glyph_ids.append(gind) + + bbox = [cvt(x, nearest=False) for x in full_font.bbox] + rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + for charname in sorted(rawcharprocs): + stream = rawcharprocs[charname] + charprocDict = {'Type': Name('XObject'), + 'Subtype': Name('Form'), + 'BBox': bbox} + # Each glyph includes bounding box information, + # but xpdf and ghostscript can't handle it in a + # Form XObject (they segfault!!!), so we remove it + # from the stream here. It's not needed anyway, + # since the Form XObject includes it in its BBox + # value. + stream = stream[stream.find(b"d1") + 2:] + charprocObject = self.reserveObject('charProc') + self.outputStream(charprocObject, stream, extra=charprocDict) + + name = self._get_xobject_glyph_name(filename, charname) + self.multi_byte_charprocs[name] = charprocObject + + # CIDToGIDMap stream + cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") + self.outputStream(cidToGidMapObject, cid_to_gid_map) + + # ToUnicode CMap + self.outputStream(toUnicodeMapObject, unicode_cmap) + + descriptor['MaxWidth'] = max_width + + # Write everything out + self.writeObject(cidFontDictObject, cidFontDict) + self.writeObject(type0FontDictObject, type0FontDict) + self.writeObject(fontdescObject, descriptor) + self.writeObject(wObject, w) + + return type0FontDictObject + + # Beginning of main embedTTF function... + + ps_name = self._get_subsetted_psname( + font.postscript_name, + font.get_charmap() + ) + ps_name = ps_name.encode('ascii', 'replace') + ps_name = Name(ps_name) + pclt = font.get_sfnt_table('pclt') or {'capHeight': 0, 'xHeight': 0} + post = font.get_sfnt_table('post') or {'italicAngle': (0, 0)} + ff = font.face_flags + sf = font.style_flags + + flags = 0 + symbolic = False # ps_name.name in ('Cmsy10', 'Cmmi10', 'Cmex10') + if FaceFlags.FIXED_WIDTH in ff: + flags |= 1 << 0 + if 0: # TODO: serif + flags |= 1 << 1 + if symbolic: + flags |= 1 << 2 + else: + flags |= 1 << 5 + if StyleFlags.ITALIC in sf: + flags |= 1 << 6 + if 0: # TODO: all caps + flags |= 1 << 16 + if 0: # TODO: small caps + flags |= 1 << 17 + if 0: # TODO: force bold + flags |= 1 << 18 + + descriptor = { + 'Type': Name('FontDescriptor'), + 'FontName': ps_name, + 'Flags': flags, + 'FontBBox': [cvt(x, nearest=False) for x in font.bbox], + 'Ascent': cvt(font.ascender, nearest=False), + 'Descent': cvt(font.descender, nearest=False), + 'CapHeight': cvt(pclt['capHeight'], nearest=False), + 'XHeight': cvt(pclt['xHeight']), + 'ItalicAngle': post['italicAngle'][1], # ??? + 'StemV': 0 # ??? + } + + if fonttype == 3: + return embedTTFType3(font, characters, descriptor) + elif fonttype == 42: + return embedTTFType42(font, characters, descriptor) + + def alphaState(self, alpha): + """Return name of an ExtGState that sets alpha to the given value.""" + + state = self.alphaStates.get(alpha, None) + if state is not None: + return state[0] + + name = next(self._alpha_state_seq) + self.alphaStates[alpha] = \ + (name, {'Type': Name('ExtGState'), + 'CA': alpha[0], 'ca': alpha[1]}) + return name + + def _soft_mask_state(self, smask): + """ + Return an ExtGState that sets the soft mask to the given shading. + + Parameters + ---------- + smask : Reference + Reference to a shading in DeviceGray color space, whose luminosity + is to be used as the alpha channel. + + Returns + ------- + Name + """ + + state = self._soft_mask_states.get(smask, None) + if state is not None: + return state[0] + + name = next(self._soft_mask_seq) + groupOb = self.reserveObject('transparency group for soft mask') + self._soft_mask_states[smask] = ( + name, + { + 'Type': Name('ExtGState'), + 'AIS': False, + 'SMask': { + 'Type': Name('Mask'), + 'S': Name('Luminosity'), + 'BC': [1], + 'G': groupOb + } + } + ) + self._soft_mask_groups.append(( + groupOb, + { + 'Type': Name('XObject'), + 'Subtype': Name('Form'), + 'FormType': 1, + 'Group': { + 'S': Name('Transparency'), + 'CS': Name('DeviceGray') + }, + 'Matrix': [1, 0, 0, 1, 0, 0], + 'Resources': {'Shading': {'S': smask}}, + 'BBox': [0, 0, 1, 1] + }, + [Name('S'), Op.shading] + )) + return name + + def writeExtGSTates(self): + self.writeObject( + self._extGStateObject, + dict([ + *self.alphaStates.values(), + *self._soft_mask_states.values() + ]) + ) + + def _write_soft_mask_groups(self): + for ob, attributes, content in self._soft_mask_groups: + self.beginStream(ob.id, None, attributes) + self.output(*content) + self.endStream() + + def hatchPattern(self, hatch_style): + # The colors may come in as numpy arrays, which aren't hashable + edge, face, hatch, lw = hatch_style + if edge is not None: + edge = tuple(edge) + if face is not None: + face = tuple(face) + hatch_style = (edge, face, hatch, lw) + + pattern = self._hatch_patterns.get(hatch_style, None) + if pattern is not None: + return pattern + + name = next(self._hatch_pattern_seq) + self._hatch_patterns[hatch_style] = name + return name + + hatchPatterns = _api.deprecated("3.10")(property(lambda self: { + k: (e, f, h) for k, (e, f, h, l) in self._hatch_patterns.items() + })) + + def writeHatches(self): + hatchDict = dict() + sidelen = 72.0 + for hatch_style, name in self._hatch_patterns.items(): + ob = self.reserveObject('hatch pattern') + hatchDict[name] = ob + res = {'Procsets': + [Name(x) for x in "PDF Text ImageB ImageC ImageI".split()]} + self.beginStream( + ob.id, None, + {'Type': Name('Pattern'), + 'PatternType': 1, 'PaintType': 1, 'TilingType': 1, + 'BBox': [0, 0, sidelen, sidelen], + 'XStep': sidelen, 'YStep': sidelen, + 'Resources': res, + # Change origin to match Agg at top-left. + 'Matrix': [1, 0, 0, 1, 0, self.height * 72]}) + + stroke_rgb, fill_rgb, hatch, lw = hatch_style + self.output(stroke_rgb[0], stroke_rgb[1], stroke_rgb[2], + Op.setrgb_stroke) + if fill_rgb is not None: + self.output(fill_rgb[0], fill_rgb[1], fill_rgb[2], + Op.setrgb_nonstroke, + 0, 0, sidelen, sidelen, Op.rectangle, + Op.fill) + + self.output(lw, Op.setlinewidth) + + self.output(*self.pathOperations( + Path.hatch(hatch), + Affine2D().scale(sidelen), + simplify=False)) + self.output(Op.fill_stroke) + + self.endStream() + self.writeObject(self.hatchObject, hatchDict) + + def addGouraudTriangles(self, points, colors): + """ + Add a Gouraud triangle shading. + + Parameters + ---------- + points : np.ndarray + Triangle vertices, shape (n, 3, 2) + where n = number of triangles, 3 = vertices, 2 = x, y. + colors : np.ndarray + Vertex colors, shape (n, 3, 1) or (n, 3, 4) + as with points, but last dimension is either (gray,) + or (r, g, b, alpha). + + Returns + ------- + Name, Reference + """ + name = Name('GT%d' % len(self.gouraudTriangles)) + ob = self.reserveObject(f'Gouraud triangle {name}') + self.gouraudTriangles.append((name, ob, points, colors)) + return name, ob + + def writeGouraudTriangles(self): + gouraudDict = dict() + for name, ob, points, colors in self.gouraudTriangles: + gouraudDict[name] = ob + shape = points.shape + flat_points = points.reshape((shape[0] * shape[1], 2)) + colordim = colors.shape[2] + assert colordim in (1, 4) + flat_colors = colors.reshape((shape[0] * shape[1], colordim)) + if colordim == 4: + # strip the alpha channel + colordim = 3 + points_min = np.min(flat_points, axis=0) - (1 << 8) + points_max = np.max(flat_points, axis=0) + (1 << 8) + factor = 0xffffffff / (points_max - points_min) + + self.beginStream( + ob.id, None, + {'ShadingType': 4, + 'BitsPerCoordinate': 32, + 'BitsPerComponent': 8, + 'BitsPerFlag': 8, + 'ColorSpace': Name( + 'DeviceRGB' if colordim == 3 else 'DeviceGray' + ), + 'AntiAlias': False, + 'Decode': ([points_min[0], points_max[0], + points_min[1], points_max[1]] + + [0, 1] * colordim), + }) + + streamarr = np.empty( + (shape[0] * shape[1],), + dtype=[('flags', 'u1'), + ('points', '>u4', (2,)), + ('colors', 'u1', (colordim,))]) + streamarr['flags'] = 0 + streamarr['points'] = (flat_points - points_min) * factor + streamarr['colors'] = flat_colors[:, :colordim] * 255.0 + + self.write(streamarr.tobytes()) + self.endStream() + self.writeObject(self.gouraudObject, gouraudDict) + + def imageObject(self, image): + """Return name of an image XObject representing the given image.""" + + entry = self._images.get(id(image), None) + if entry is not None: + return entry[1] + + name = next(self._image_seq) + ob = self.reserveObject(f'image {name}') + self._images[id(image)] = (image, name, ob) + return name + + def _unpack(self, im): + """ + Unpack image array *im* into ``(data, alpha)``, which have shape + ``(height, width, 3)`` (RGB) or ``(height, width, 1)`` (grayscale or + alpha), except that alpha is None if the image is fully opaque. + """ + im = im[::-1] + if im.ndim == 2: + return im, None + else: + rgb = im[:, :, :3] + rgb = np.array(rgb, order='C') + # PDF needs a separate alpha image + if im.shape[2] == 4: + alpha = im[:, :, 3][..., None] + if np.all(alpha == 255): + alpha = None + else: + alpha = np.array(alpha, order='C') + else: + alpha = None + return rgb, alpha + + def _writePng(self, img): + """ + Write the image *img* into the pdf file using png + predictors with Flate compression. + """ + buffer = BytesIO() + img.save(buffer, format="png") + buffer.seek(8) + png_data = b'' + bit_depth = palette = None + while True: + length, type = struct.unpack(b'!L4s', buffer.read(8)) + if type in [b'IHDR', b'PLTE', b'IDAT']: + data = buffer.read(length) + if len(data) != length: + raise RuntimeError("truncated data") + if type == b'IHDR': + bit_depth = int(data[8]) + elif type == b'PLTE': + palette = data + elif type == b'IDAT': + png_data += data + elif type == b'IEND': + break + else: + buffer.seek(length, 1) + buffer.seek(4, 1) # skip CRC + return png_data, bit_depth, palette + + def _writeImg(self, data, id, smask=None): + """ + Write the image *data*, of shape ``(height, width, 1)`` (grayscale) or + ``(height, width, 3)`` (RGB), as pdf object *id* and with the soft mask + (alpha channel) *smask*, which should be either None or a ``(height, + width, 1)`` array. + """ + height, width, color_channels = data.shape + obj = {'Type': Name('XObject'), + 'Subtype': Name('Image'), + 'Width': width, + 'Height': height, + 'ColorSpace': Name({1: 'DeviceGray', 3: 'DeviceRGB'}[color_channels]), + 'BitsPerComponent': 8} + if smask: + obj['SMask'] = smask + if mpl.rcParams['pdf.compression']: + if data.shape[-1] == 1: + data = data.squeeze(axis=-1) + png = {'Predictor': 10, 'Colors': color_channels, 'Columns': width} + img = Image.fromarray(data) + img_colors = img.getcolors(maxcolors=256) + if color_channels == 3 and img_colors is not None: + # Convert to indexed color if there are 256 colors or fewer. This can + # significantly reduce the file size. + num_colors = len(img_colors) + palette = np.array([comp for _, color in img_colors for comp in color], + dtype=np.uint8) + palette24 = ((palette[0::3].astype(np.uint32) << 16) | + (palette[1::3].astype(np.uint32) << 8) | + palette[2::3]) + rgb24 = ((data[:, :, 0].astype(np.uint32) << 16) | + (data[:, :, 1].astype(np.uint32) << 8) | + data[:, :, 2]) + indices = np.argsort(palette24).astype(np.uint8) + rgb8 = indices[np.searchsorted(palette24, rgb24, sorter=indices)] + img = Image.fromarray(rgb8, mode='P') + img.putpalette(palette) + png_data, bit_depth, palette = self._writePng(img) + if bit_depth is None or palette is None: + raise RuntimeError("invalid PNG header") + palette = palette[:num_colors * 3] # Trim padding; remove for Pillow>=9 + obj['ColorSpace'] = [Name('Indexed'), Name('DeviceRGB'), + num_colors - 1, palette] + obj['BitsPerComponent'] = bit_depth + png['Colors'] = 1 + png['BitsPerComponent'] = bit_depth + else: + png_data, _, _ = self._writePng(img) + else: + png = None + self.beginStream( + id, + self.reserveObject('length of image stream'), + obj, + png=png + ) + if png: + self.currentstream.write(png_data) + else: + self.currentstream.write(data.tobytes()) + self.endStream() + + def writeImages(self): + for img, name, ob in self._images.values(): + data, adata = self._unpack(img) + if adata is not None: + smaskObject = self.reserveObject("smask") + self._writeImg(adata, smaskObject.id) + else: + smaskObject = None + self._writeImg(data, ob.id, smaskObject) + + def markerObject(self, path, trans, fill, stroke, lw, joinstyle, + capstyle): + """Return name of a marker XObject representing the given path.""" + # self.markers used by markerObject, writeMarkers, close: + # mapping from (path operations, fill?, stroke?) to + # [name, object reference, bounding box, linewidth] + # This enables different draw_markers calls to share the XObject + # if the gc is sufficiently similar: colors etc can vary, but + # the choices of whether to fill and whether to stroke cannot. + # We need a bounding box enclosing all of the XObject path, + # but since line width may vary, we store the maximum of all + # occurring line widths in self.markers. + # close() is somewhat tightly coupled in that it expects the + # first two components of each value in self.markers to be the + # name and object reference. + pathops = self.pathOperations(path, trans, simplify=False) + key = (tuple(pathops), bool(fill), bool(stroke), joinstyle, capstyle) + result = self.markers.get(key) + if result is None: + name = Name('M%d' % len(self.markers)) + ob = self.reserveObject('marker %d' % len(self.markers)) + bbox = path.get_extents(trans) + self.markers[key] = [name, ob, bbox, lw] + else: + if result[-1] < lw: + result[-1] = lw + name = result[0] + return name + + def writeMarkers(self): + for ((pathops, fill, stroke, joinstyle, capstyle), + (name, ob, bbox, lw)) in self.markers.items(): + # bbox wraps the exact limits of the control points, so half a line + # will appear outside it. If the join style is miter and the line + # is not parallel to the edge, then the line will extend even + # further. From the PDF specification, Section 8.4.3.5, the miter + # limit is miterLength / lineWidth and from Table 52, the default + # is 10. With half the miter length outside, that works out to the + # following padding: + bbox = bbox.padded(lw * 5) + self.beginStream( + ob.id, None, + {'Type': Name('XObject'), 'Subtype': Name('Form'), + 'BBox': list(bbox.extents)}) + self.output(GraphicsContextPdf.joinstyles[joinstyle], + Op.setlinejoin) + self.output(GraphicsContextPdf.capstyles[capstyle], Op.setlinecap) + self.output(*pathops) + self.output(Op.paint_path(fill, stroke)) + self.endStream() + + def pathCollectionObject(self, gc, path, trans, padding, filled, stroked): + name = Name('P%d' % len(self.paths)) + ob = self.reserveObject('path %d' % len(self.paths)) + self.paths.append( + (name, path, trans, ob, gc.get_joinstyle(), gc.get_capstyle(), + padding, filled, stroked)) + return name + + def writePathCollectionTemplates(self): + for (name, path, trans, ob, joinstyle, capstyle, padding, filled, + stroked) in self.paths: + pathops = self.pathOperations(path, trans, simplify=False) + bbox = path.get_extents(trans) + if not np.all(np.isfinite(bbox.extents)): + extents = [0, 0, 0, 0] + else: + bbox = bbox.padded(padding) + extents = list(bbox.extents) + self.beginStream( + ob.id, None, + {'Type': Name('XObject'), 'Subtype': Name('Form'), + 'BBox': extents}) + self.output(GraphicsContextPdf.joinstyles[joinstyle], + Op.setlinejoin) + self.output(GraphicsContextPdf.capstyles[capstyle], Op.setlinecap) + self.output(*pathops) + self.output(Op.paint_path(filled, stroked)) + self.endStream() + + @staticmethod + def pathOperations(path, transform, clip=None, simplify=None, sketch=None): + return [Verbatim(_path.convert_to_string( + path, transform, clip, simplify, sketch, + 6, + [Op.moveto.value, Op.lineto.value, b'', Op.curveto.value, + Op.closepath.value], + True))] + + def writePath(self, path, transform, clip=False, sketch=None): + if clip: + clip = (0.0, 0.0, self.width * 72, self.height * 72) + simplify = path.should_simplify + else: + clip = None + simplify = False + cmds = self.pathOperations(path, transform, clip, simplify=simplify, + sketch=sketch) + self.output(*cmds) + + def reserveObject(self, name=''): + """ + Reserve an ID for an indirect object. + + The name is used for debugging in case we forget to print out + the object with writeObject. + """ + id = next(self._object_seq) + self.xrefTable.append([None, 0, name]) + return Reference(id) + + def recordXref(self, id): + self.xrefTable[id][0] = self.fh.tell() - self.tell_base + + def writeObject(self, object, contents): + self.recordXref(object.id) + object.write(contents, self) + + def writeXref(self): + """Write out the xref table.""" + self.startxref = self.fh.tell() - self.tell_base + self.write(b"xref\n0 %d\n" % len(self.xrefTable)) + for i, (offset, generation, name) in enumerate(self.xrefTable): + if offset is None: + raise AssertionError( + 'No offset for object %d (%s)' % (i, name)) + else: + key = b"f" if name == 'the zero object' else b"n" + text = b"%010d %05d %b \n" % (offset, generation, key) + self.write(text) + + def writeInfoDict(self): + """Write out the info dictionary, checking it for good form""" + + self.infoObject = self.reserveObject('info') + self.writeObject(self.infoObject, self.infoDict) + + def writeTrailer(self): + """Write out the PDF trailer.""" + + self.write(b"trailer\n") + self.write(pdfRepr( + {'Size': len(self.xrefTable), + 'Root': self.rootObject, + 'Info': self.infoObject})) + # Could add 'ID' + self.write(b"\nstartxref\n%d\n%%%%EOF\n" % self.startxref) + + +class RendererPdf(_backend_pdf_ps.RendererPDFPSBase): + + _afm_font_dir = cbook._get_data_path("fonts/pdfcorefonts") + _use_afm_rc_name = "pdf.use14corefonts" + + def __init__(self, file, image_dpi, height, width): + super().__init__(width, height) + self.file = file + self.gc = self.new_gc() + self.image_dpi = image_dpi + + def finalize(self): + self.file.output(*self.gc.finalize()) + + def check_gc(self, gc, fillcolor=None): + orig_fill = getattr(gc, '_fillcolor', (0., 0., 0.)) + gc._fillcolor = fillcolor + + orig_alphas = getattr(gc, '_effective_alphas', (1.0, 1.0)) + + if gc.get_rgb() is None: + # It should not matter what color here since linewidth should be + # 0 unless affected by global settings in rcParams, hence setting + # zero alpha just in case. + gc.set_foreground((0, 0, 0, 0), isRGBA=True) + + if gc._forced_alpha: + gc._effective_alphas = (gc._alpha, gc._alpha) + elif fillcolor is None or len(fillcolor) < 4: + gc._effective_alphas = (gc._rgb[3], 1.0) + else: + gc._effective_alphas = (gc._rgb[3], fillcolor[3]) + + delta = self.gc.delta(gc) + if delta: + self.file.output(*delta) + + # Restore gc to avoid unwanted side effects + gc._fillcolor = orig_fill + gc._effective_alphas = orig_alphas + + def get_image_magnification(self): + return self.image_dpi/72.0 + + def draw_image(self, gc, x, y, im, transform=None): + # docstring inherited + + h, w = im.shape[:2] + if w == 0 or h == 0: + return + + if transform is None: + # If there's no transform, alpha has already been applied + gc.set_alpha(1.0) + + self.check_gc(gc) + + w = 72.0 * w / self.image_dpi + h = 72.0 * h / self.image_dpi + + imob = self.file.imageObject(im) + + if transform is None: + self.file.output(Op.gsave, + w, 0, 0, h, x, y, Op.concat_matrix, + imob, Op.use_xobject, Op.grestore) + else: + tr1, tr2, tr3, tr4, tr5, tr6 = transform.frozen().to_values() + + self.file.output(Op.gsave, + 1, 0, 0, 1, x, y, Op.concat_matrix, + tr1, tr2, tr3, tr4, tr5, tr6, Op.concat_matrix, + imob, Op.use_xobject, Op.grestore) + + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + self.check_gc(gc, rgbFace) + self.file.writePath( + path, transform, + rgbFace is None and gc.get_hatch_path() is None, + gc.get_sketch_params()) + self.file.output(self.gc.paint()) + + def draw_path_collection(self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position): + # We can only reuse the objects if the presence of fill and + # stroke (and the amount of alpha for each) is the same for + # all of them + can_do_optimization = True + facecolors = np.asarray(facecolors) + edgecolors = np.asarray(edgecolors) + + if not len(facecolors): + filled = False + can_do_optimization = not gc.get_hatch() + else: + if np.all(facecolors[:, 3] == facecolors[0, 3]): + filled = facecolors[0, 3] != 0.0 + else: + can_do_optimization = False + + if not len(edgecolors): + stroked = False + else: + if np.all(np.asarray(linewidths) == 0.0): + stroked = False + elif np.all(edgecolors[:, 3] == edgecolors[0, 3]): + stroked = edgecolors[0, 3] != 0.0 + else: + can_do_optimization = False + + # Is the optimization worth it? Rough calculation: + # cost of emitting a path in-line is len_path * uses_per_path + # cost of XObject is len_path + 5 for the definition, + # uses_per_path for the uses + len_path = len(paths[0].vertices) if len(paths) > 0 else 0 + uses_per_path = self._iter_collection_uses_per_path( + paths, all_transforms, offsets, facecolors, edgecolors) + should_do_optimization = \ + len_path + uses_per_path + 5 < len_path * uses_per_path + + if (not can_do_optimization) or (not should_do_optimization): + return RendererBase.draw_path_collection( + self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position) + + padding = np.max(linewidths) + path_codes = [] + for i, (path, transform) in enumerate(self._iter_collection_raw_paths( + master_transform, paths, all_transforms)): + name = self.file.pathCollectionObject( + gc, path, transform, padding, filled, stroked) + path_codes.append(name) + + output = self.file.output + output(*self.gc.push()) + lastx, lasty = 0, 0 + for xo, yo, path_id, gc0, rgbFace in self._iter_collection( + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, + antialiaseds, urls, offset_position): + + self.check_gc(gc0, rgbFace) + dx, dy = xo - lastx, yo - lasty + output(1, 0, 0, 1, dx, dy, Op.concat_matrix, path_id, + Op.use_xobject) + lastx, lasty = xo, yo + output(*self.gc.pop()) + + def draw_markers(self, gc, marker_path, marker_trans, path, trans, + rgbFace=None): + # docstring inherited + + # Same logic as in draw_path_collection + len_marker_path = len(marker_path) + uses = len(path) + if len_marker_path * uses < len_marker_path + uses + 5: + RendererBase.draw_markers(self, gc, marker_path, marker_trans, + path, trans, rgbFace) + return + + self.check_gc(gc, rgbFace) + fill = gc.fill(rgbFace) + stroke = gc.stroke() + + output = self.file.output + marker = self.file.markerObject( + marker_path, marker_trans, fill, stroke, self.gc._linewidth, + gc.get_joinstyle(), gc.get_capstyle()) + + output(Op.gsave) + lastx, lasty = 0, 0 + for vertices, code in path.iter_segments( + trans, + clip=(0, 0, self.file.width*72, self.file.height*72), + simplify=False): + if len(vertices): + x, y = vertices[-2:] + if not (0 <= x <= self.file.width * 72 + and 0 <= y <= self.file.height * 72): + continue + dx, dy = x - lastx, y - lasty + output(1, 0, 0, 1, dx, dy, Op.concat_matrix, + marker, Op.use_xobject) + lastx, lasty = x, y + output(Op.grestore) + + def draw_gouraud_triangles(self, gc, points, colors, trans): + assert len(points) == len(colors) + if len(points) == 0: + return + assert points.ndim == 3 + assert points.shape[1] == 3 + assert points.shape[2] == 2 + assert colors.ndim == 3 + assert colors.shape[1] == 3 + assert colors.shape[2] in (1, 4) + + shape = points.shape + points = points.reshape((shape[0] * shape[1], 2)) + tpoints = trans.transform(points) + tpoints = tpoints.reshape(shape) + name, _ = self.file.addGouraudTriangles(tpoints, colors) + output = self.file.output + + if colors.shape[2] == 1: + # grayscale + gc.set_alpha(1.0) + self.check_gc(gc) + output(name, Op.shading) + return + + alpha = colors[0, 0, 3] + if np.allclose(alpha, colors[:, :, 3]): + # single alpha value + gc.set_alpha(alpha) + self.check_gc(gc) + output(name, Op.shading) + else: + # varying alpha: use a soft mask + alpha = colors[:, :, 3][:, :, None] + _, smask_ob = self.file.addGouraudTriangles(tpoints, alpha) + gstate = self.file._soft_mask_state(smask_ob) + output(Op.gsave, gstate, Op.setgstate, + name, Op.shading, + Op.grestore) + + def _setup_textpos(self, x, y, angle, oldx=0, oldy=0, oldangle=0): + if angle == oldangle == 0: + self.file.output(x - oldx, y - oldy, Op.textpos) + else: + angle = math.radians(angle) + self.file.output(math.cos(angle), math.sin(angle), + -math.sin(angle), math.cos(angle), + x, y, Op.textmatrix) + self.file.output(0, 0, Op.textpos) + + def draw_mathtext(self, gc, x, y, s, prop, angle): + # TODO: fix positioning and encoding + width, height, descent, glyphs, rects = \ + self._text2path.mathtext_parser.parse(s, 72, prop) + + if gc.get_url() is not None: + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, width, height, angle)) + + fonttype = mpl.rcParams['pdf.fonttype'] + + # Set up a global transformation matrix for the whole math expression + a = math.radians(angle) + self.file.output(Op.gsave) + self.file.output(math.cos(a), math.sin(a), + -math.sin(a), math.cos(a), + x, y, Op.concat_matrix) + + self.check_gc(gc, gc._rgb) + prev_font = None, None + oldx, oldy = 0, 0 + unsupported_chars = [] + + self.file.output(Op.begin_text) + for font, fontsize, num, ox, oy in glyphs: + self.file._character_tracker.track_glyph(font, num) + fontname = font.fname + if not _font_supports_glyph(fonttype, num): + # Unsupported chars (i.e. multibyte in Type 3 or beyond BMP in + # Type 42) must be emitted separately (below). + unsupported_chars.append((font, fontsize, ox, oy, num)) + else: + self._setup_textpos(ox, oy, 0, oldx, oldy) + oldx, oldy = ox, oy + if (fontname, fontsize) != prev_font: + self.file.output(self.file.fontName(fontname), fontsize, + Op.selectfont) + prev_font = fontname, fontsize + self.file.output(self.encode_string(chr(num), fonttype), + Op.show) + self.file.output(Op.end_text) + + for font, fontsize, ox, oy, num in unsupported_chars: + self._draw_xobject_glyph( + font, fontsize, font.get_char_index(num), ox, oy) + + # Draw any horizontal lines in the math layout + for ox, oy, width, height in rects: + self.file.output(Op.gsave, ox, oy, width, height, + Op.rectangle, Op.fill, Op.grestore) + + # Pop off the global transformation + self.file.output(Op.grestore) + + def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): + # docstring inherited + texmanager = self.get_texmanager() + fontsize = prop.get_size_in_points() + dvifile = texmanager.make_dvi(s, fontsize) + with dviread.Dvi(dvifile, 72) as dvi: + page, = dvi + + if gc.get_url() is not None: + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, page.width, page.height, angle)) + + # Gather font information and do some setup for combining + # characters into strings. The variable seq will contain a + # sequence of font and text entries. A font entry is a list + # ['font', name, size] where name is a Name object for the + # font. A text entry is ['text', x, y, glyphs, x+w] where x + # and y are the starting coordinates, w is the width, and + # glyphs is a list; in this phase it will always contain just + # one single-character string, but later it may have longer + # strings interspersed with kern amounts. + oldfont, seq = None, [] + for x1, y1, dvifont, glyph, width in page.text: + if dvifont != oldfont: + pdfname = self.file.dviFontName(dvifont) + seq += [['font', pdfname, dvifont.size]] + oldfont = dvifont + seq += [['text', x1, y1, [bytes([glyph])], x1+width]] + + # Find consecutive text strings with constant y coordinate and + # combine into a sequence of strings and kerns, or just one + # string (if any kerns would be less than 0.1 points). + i, curx, fontsize = 0, 0, None + while i < len(seq)-1: + elt, nxt = seq[i:i+2] + if elt[0] == 'font': + fontsize = elt[2] + elif elt[0] == nxt[0] == 'text' and elt[2] == nxt[2]: + offset = elt[4] - nxt[1] + if abs(offset) < 0.1: + elt[3][-1] += nxt[3][0] + elt[4] += nxt[4]-nxt[1] + else: + elt[3] += [offset*1000.0/fontsize, nxt[3][0]] + elt[4] = nxt[4] + del seq[i+1] + continue + i += 1 + + # Create a transform to map the dvi contents to the canvas. + mytrans = Affine2D().rotate_deg(angle).translate(x, y) + + # Output the text. + self.check_gc(gc, gc._rgb) + self.file.output(Op.begin_text) + curx, cury, oldx, oldy = 0, 0, 0, 0 + for elt in seq: + if elt[0] == 'font': + self.file.output(elt[1], elt[2], Op.selectfont) + elif elt[0] == 'text': + curx, cury = mytrans.transform((elt[1], elt[2])) + self._setup_textpos(curx, cury, angle, oldx, oldy) + oldx, oldy = curx, cury + if len(elt[3]) == 1: + self.file.output(elt[3][0], Op.show) + else: + self.file.output(elt[3], Op.showkern) + else: + assert False + self.file.output(Op.end_text) + + # Then output the boxes (e.g., variable-length lines of square + # roots). + boxgc = self.new_gc() + boxgc.copy_properties(gc) + boxgc.set_linewidth(0) + pathops = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, + Path.CLOSEPOLY] + for x1, y1, h, w in page.boxes: + path = Path([[x1, y1], [x1+w, y1], [x1+w, y1+h], [x1, y1+h], + [0, 0]], pathops) + self.draw_path(boxgc, path, mytrans, gc._rgb) + + def encode_string(self, s, fonttype): + if fonttype in (1, 3): + return s.encode('cp1252', 'replace') + return s.encode('utf-16be', 'replace') + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + + # TODO: combine consecutive texts into one BT/ET delimited section + + self.check_gc(gc, gc._rgb) + if ismath: + return self.draw_mathtext(gc, x, y, s, prop, angle) + + fontsize = prop.get_size_in_points() + + if mpl.rcParams['pdf.use14corefonts']: + font = self._get_font_afm(prop) + fonttype = 1 + else: + font = self._get_font_ttf(prop) + self.file._character_tracker.track(font, s) + fonttype = mpl.rcParams['pdf.fonttype'] + + if gc.get_url() is not None: + font.set_text(s) + width, height = font.get_width_height() + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, width / 64, height / 64, angle)) + + # If fonttype is neither 3 nor 42, emit the whole string at once + # without manual kerning. + if fonttype not in [3, 42]: + self.file.output(Op.begin_text, + self.file.fontName(prop), fontsize, Op.selectfont) + self._setup_textpos(x, y, angle) + self.file.output(self.encode_string(s, fonttype), + Op.show, Op.end_text) + + # A sequence of characters is broken into multiple chunks. The chunking + # serves two purposes: + # - For Type 3 fonts, there is no way to access multibyte characters, + # as they cannot have a CIDMap. Therefore, in this case we break + # the string into chunks, where each chunk contains either a string + # of consecutive 1-byte characters or a single multibyte character. + # - A sequence of 1-byte characters is split into chunks to allow for + # kerning adjustments between consecutive chunks. + # + # Each chunk is emitted with a separate command: 1-byte characters use + # the regular text show command (TJ) with appropriate kerning between + # chunks, whereas multibyte characters use the XObject command (Do). + else: + # List of (ft_object, start_x, [prev_kern, char, char, ...]), + # w/o zero kerns. + singlebyte_chunks = [] + # List of (ft_object, start_x, glyph_index). + multibyte_glyphs = [] + prev_was_multibyte = True + prev_font = font + for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED): + if _font_supports_glyph(fonttype, ord(item.char)): + if prev_was_multibyte or item.ft_object != prev_font: + singlebyte_chunks.append((item.ft_object, item.x, [])) + prev_font = item.ft_object + if item.prev_kern: + singlebyte_chunks[-1][2].append(item.prev_kern) + singlebyte_chunks[-1][2].append(item.char) + prev_was_multibyte = False + else: + multibyte_glyphs.append((item.ft_object, item.x, item.glyph_idx)) + prev_was_multibyte = True + # Do the rotation and global translation as a single matrix + # concatenation up front + self.file.output(Op.gsave) + a = math.radians(angle) + self.file.output(math.cos(a), math.sin(a), + -math.sin(a), math.cos(a), + x, y, Op.concat_matrix) + # Emit all the 1-byte characters in a BT/ET group. + + self.file.output(Op.begin_text) + prev_start_x = 0 + for ft_object, start_x, kerns_or_chars in singlebyte_chunks: + ft_name = self.file.fontName(ft_object.fname) + self.file.output(ft_name, fontsize, Op.selectfont) + self._setup_textpos(start_x, 0, 0, prev_start_x, 0, 0) + self.file.output( + # See pdf spec "Text space details" for the 1000/fontsize + # (aka. 1000/T_fs) factor. + [-1000 * next(group) / fontsize if tp == float # a kern + else self.encode_string("".join(group), fonttype) + for tp, group in itertools.groupby(kerns_or_chars, type)], + Op.showkern) + prev_start_x = start_x + self.file.output(Op.end_text) + # Then emit all the multibyte characters, one at a time. + for ft_object, start_x, glyph_idx in multibyte_glyphs: + self._draw_xobject_glyph( + ft_object, fontsize, glyph_idx, start_x, 0 + ) + self.file.output(Op.grestore) + + def _draw_xobject_glyph(self, font, fontsize, glyph_idx, x, y): + """Draw a multibyte character from a Type 3 font as an XObject.""" + glyph_name = font.get_glyph_name(glyph_idx) + name = self.file._get_xobject_glyph_name(font.fname, glyph_name) + self.file.output( + Op.gsave, + 0.001 * fontsize, 0, 0, 0.001 * fontsize, x, y, Op.concat_matrix, + Name(name), Op.use_xobject, + Op.grestore, + ) + + def new_gc(self): + # docstring inherited + return GraphicsContextPdf(self.file) + + +class GraphicsContextPdf(GraphicsContextBase): + + def __init__(self, file): + super().__init__() + self._fillcolor = (0.0, 0.0, 0.0) + self._effective_alphas = (1.0, 1.0) + self.file = file + self.parent = None + + def __repr__(self): + d = dict(self.__dict__) + del d['file'] + del d['parent'] + return repr(d) + + def stroke(self): + """ + Predicate: does the path need to be stroked (its outline drawn)? + This tests for the various conditions that disable stroking + the path, in which case it would presumably be filled. + """ + # _linewidth > 0: in pdf a line of width 0 is drawn at minimum + # possible device width, but e.g., agg doesn't draw at all + return (self._linewidth > 0 and self._alpha > 0 and + (len(self._rgb) <= 3 or self._rgb[3] != 0.0)) + + def fill(self, *args): + """ + Predicate: does the path need to be filled? + + An optional argument can be used to specify an alternative + _fillcolor, as needed by RendererPdf.draw_markers. + """ + if len(args): + _fillcolor = args[0] + else: + _fillcolor = self._fillcolor + return (self._hatch or + (_fillcolor is not None and + (len(_fillcolor) <= 3 or _fillcolor[3] != 0.0))) + + def paint(self): + """ + Return the appropriate pdf operator to cause the path to be + stroked, filled, or both. + """ + return Op.paint_path(self.fill(), self.stroke()) + + capstyles = {'butt': 0, 'round': 1, 'projecting': 2} + joinstyles = {'miter': 0, 'round': 1, 'bevel': 2} + + def capstyle_cmd(self, style): + return [self.capstyles[style], Op.setlinecap] + + def joinstyle_cmd(self, style): + return [self.joinstyles[style], Op.setlinejoin] + + def linewidth_cmd(self, width): + return [width, Op.setlinewidth] + + def dash_cmd(self, dashes): + offset, dash = dashes + if dash is None: + dash = [] + offset = 0 + return [list(dash), offset, Op.setdash] + + def alpha_cmd(self, alpha, forced, effective_alphas): + name = self.file.alphaState(effective_alphas) + return [name, Op.setgstate] + + def hatch_cmd(self, hatch, hatch_color, hatch_linewidth): + if not hatch: + if self._fillcolor is not None: + return self.fillcolor_cmd(self._fillcolor) + else: + return [Name('DeviceRGB'), Op.setcolorspace_nonstroke] + else: + hatch_style = (hatch_color, self._fillcolor, hatch, hatch_linewidth) + name = self.file.hatchPattern(hatch_style) + return [Name('Pattern'), Op.setcolorspace_nonstroke, + name, Op.setcolor_nonstroke] + + def rgb_cmd(self, rgb): + if mpl.rcParams['pdf.inheritcolor']: + return [] + if rgb[0] == rgb[1] == rgb[2]: + return [rgb[0], Op.setgray_stroke] + else: + return [*rgb[:3], Op.setrgb_stroke] + + def fillcolor_cmd(self, rgb): + if rgb is None or mpl.rcParams['pdf.inheritcolor']: + return [] + elif rgb[0] == rgb[1] == rgb[2]: + return [rgb[0], Op.setgray_nonstroke] + else: + return [*rgb[:3], Op.setrgb_nonstroke] + + def push(self): + parent = GraphicsContextPdf(self.file) + parent.copy_properties(self) + parent.parent = self.parent + self.parent = parent + return [Op.gsave] + + def pop(self): + assert self.parent is not None + self.copy_properties(self.parent) + self.parent = self.parent.parent + return [Op.grestore] + + def clip_cmd(self, cliprect, clippath): + """Set clip rectangle. Calls `.pop()` and `.push()`.""" + cmds = [] + # Pop graphics state until we hit the right one or the stack is empty + while ((self._cliprect, self._clippath) != (cliprect, clippath) + and self.parent is not None): + cmds.extend(self.pop()) + # Unless we hit the right one, set the clip polygon + if ((self._cliprect, self._clippath) != (cliprect, clippath) or + self.parent is None): + cmds.extend(self.push()) + if self._cliprect != cliprect: + cmds.extend([cliprect, Op.rectangle, Op.clip, Op.endpath]) + if self._clippath != clippath: + path, affine = clippath.get_transformed_path_and_affine() + cmds.extend( + PdfFile.pathOperations(path, affine, simplify=False) + + [Op.clip, Op.endpath]) + return cmds + + commands = ( + # must come first since may pop + (('_cliprect', '_clippath'), clip_cmd), + (('_alpha', '_forced_alpha', '_effective_alphas'), alpha_cmd), + (('_capstyle',), capstyle_cmd), + (('_fillcolor',), fillcolor_cmd), + (('_joinstyle',), joinstyle_cmd), + (('_linewidth',), linewidth_cmd), + (('_dashes',), dash_cmd), + (('_rgb',), rgb_cmd), + # must come after fillcolor and rgb + (('_hatch', '_hatch_color', '_hatch_linewidth'), hatch_cmd), + ) + + def delta(self, other): + """ + Copy properties of other into self and return PDF commands + needed to transform *self* into *other*. + """ + cmds = [] + fill_performed = False + for params, cmd in self.commands: + different = False + for p in params: + ours = getattr(self, p) + theirs = getattr(other, p) + try: + if ours is None or theirs is None: + different = ours is not theirs + else: + different = bool(ours != theirs) + except ValueError: + ours = np.asarray(ours) + theirs = np.asarray(theirs) + different = (ours.shape != theirs.shape or + np.any(ours != theirs)) + if different: + break + + # Need to update hatching if we also updated fillcolor + if cmd.__name__ == 'hatch_cmd' and fill_performed: + different = True + + if different: + if cmd.__name__ == 'fillcolor_cmd': + fill_performed = True + theirs = [getattr(other, p) for p in params] + cmds.extend(cmd(self, *theirs)) + for p in params: + setattr(self, p, getattr(other, p)) + return cmds + + def copy_properties(self, other): + """ + Copy properties of other into self. + """ + super().copy_properties(other) + fillcolor = getattr(other, '_fillcolor', self._fillcolor) + effective_alphas = getattr(other, '_effective_alphas', + self._effective_alphas) + self._fillcolor = fillcolor + self._effective_alphas = effective_alphas + + def finalize(self): + """ + Make sure every pushed graphics state is popped. + """ + cmds = [] + while self.parent is not None: + cmds.extend(self.pop()) + return cmds + + +class PdfPages: + """ + A multi-page PDF file. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> # Initialize: + >>> with PdfPages('foo.pdf') as pdf: + ... # As many times as you like, create a figure fig and save it: + ... fig = plt.figure() + ... pdf.savefig(fig) + ... # When no figure is specified the current figure is saved + ... pdf.savefig() + + Notes + ----- + In reality `PdfPages` is a thin wrapper around `PdfFile`, in order to avoid + confusion when using `~.pyplot.savefig` and forgetting the format argument. + """ + + @_api.delete_parameter("3.10", "keep_empty", + addendum="This parameter does nothing.") + def __init__(self, filename, keep_empty=None, metadata=None): + """ + Create a new PdfPages object. + + Parameters + ---------- + filename : str or path-like or file-like + Plots using `PdfPages.savefig` will be written to a file at this location. + The file is opened when a figure is saved for the first time (overwriting + any older file with the same name). + + metadata : dict, optional + Information dictionary object (see PDF reference section 10.2.1 + 'Document Information Dictionary'), e.g.: + ``{'Creator': 'My software', 'Author': 'Me', 'Title': 'Awesome'}``. + + The standard keys are 'Title', 'Author', 'Subject', 'Keywords', + 'Creator', 'Producer', 'CreationDate', 'ModDate', and + 'Trapped'. Values have been predefined for 'Creator', 'Producer' + and 'CreationDate'. They can be removed by setting them to `None`. + """ + self._filename = filename + self._metadata = metadata + self._file = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def _ensure_file(self): + if self._file is None: + self._file = PdfFile(self._filename, metadata=self._metadata) # init. + return self._file + + def close(self): + """ + Finalize this object, making the underlying file a complete + PDF file. + """ + if self._file is not None: + self._file.finalize() + self._file.close() + self._file = None + + def infodict(self): + """ + Return a modifiable information dictionary object + (see PDF reference section 10.2.1 'Document Information + Dictionary'). + """ + return self._ensure_file().infoDict + + def savefig(self, figure=None, **kwargs): + """ + Save a `.Figure` to this file as a new page. + + Any other keyword arguments are passed to `~.Figure.savefig`. + + Parameters + ---------- + figure : `.Figure` or int, default: the active figure + The figure, or index of the figure, that is saved to the file. + """ + if not isinstance(figure, Figure): + if figure is None: + manager = Gcf.get_active() + else: + manager = Gcf.get_fig_manager(figure) + if manager is None: + raise ValueError(f"No figure {figure}") + figure = manager.canvas.figure + # Force use of pdf backend, as PdfPages is tightly coupled with it. + figure.savefig(self, format="pdf", backend="pdf", **kwargs) + + def get_pagecount(self): + """Return the current number of pages in the multipage pdf file.""" + return len(self._ensure_file().pageList) + + def attach_note(self, text, positionRect=[-100, -100, 0, 0]): + """ + Add a new text note to the page to be saved next. The optional + positionRect specifies the position of the new note on the + page. It is outside the page per default to make sure it is + invisible on printouts. + """ + self._ensure_file().newTextnote(text, positionRect) + + +class FigureCanvasPdf(FigureCanvasBase): + # docstring inherited + + fixed_dpi = 72 + filetypes = {'pdf': 'Portable Document Format'} + + def get_default_filetype(self): + return 'pdf' + + def print_pdf(self, filename, *, + bbox_inches_restore=None, metadata=None): + + dpi = self.figure.dpi + self.figure.dpi = 72 # there are 72 pdf points to an inch + width, height = self.figure.get_size_inches() + if isinstance(filename, PdfPages): + file = filename._ensure_file() + else: + file = PdfFile(filename, metadata=metadata) + try: + file.newPage(width, height) + renderer = MixedModeRenderer( + self.figure, width, height, dpi, + RendererPdf(file, dpi, height, width), + bbox_inches_restore=bbox_inches_restore) + self.figure.draw(renderer) + renderer.finalize() + if not isinstance(filename, PdfPages): + file.finalize() + finally: + if isinstance(filename, PdfPages): # finish off this page + file.endStream() + else: # we opened the file above; now finish it off + file.close() + + def draw(self): + self.figure.draw_without_rendering() + return super().draw() + + +FigureManagerPdf = FigureManagerBase + + +@_Backend.export +class _BackendPdf(_Backend): + FigureCanvas = FigureCanvasPdf diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_pgf.py b/lib/python3.12/site-packages/matplotlib/backends/backend_pgf.py new file mode 100644 index 0000000000000000000000000000000000000000..48b6e8ac152c7a7d832dad2691534e1ecc0b2fae --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_pgf.py @@ -0,0 +1,1010 @@ +import codecs +import datetime +import functools +from io import BytesIO +import logging +import math +import os +import pathlib +import shutil +import subprocess +from tempfile import TemporaryDirectory +import weakref + +from PIL import Image + +import matplotlib as mpl +from matplotlib import cbook, font_manager as fm +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase +) +from matplotlib.backends.backend_mixed import MixedModeRenderer +from matplotlib.backends.backend_pdf import ( + _create_pdf_info_dict, _datetime_to_pdf) +from matplotlib.path import Path +from matplotlib.figure import Figure +from matplotlib.font_manager import FontProperties +from matplotlib._pylab_helpers import Gcf + +_log = logging.getLogger(__name__) + + +_DOCUMENTCLASS = r"\documentclass{article}" + + +# Note: When formatting floating point values, it is important to use the +# %f/{:f} format rather than %s/{} to avoid triggering scientific notation, +# which is not recognized by TeX. + +def _get_preamble(): + """Prepare a LaTeX preamble based on the rcParams configuration.""" + font_size_pt = FontProperties( + size=mpl.rcParams["font.size"] + ).get_size_in_points() + return "\n".join([ + # Remove Matplotlib's custom command \mathdefault. (Not using + # \mathnormal instead since this looks odd with Computer Modern.) + r"\def\mathdefault#1{#1}", + # Use displaystyle for all math. + r"\everymath=\expandafter{\the\everymath\displaystyle}", + # Set up font sizes to match font.size setting. + # If present, use the KOMA package scrextend to adjust the standard + # LaTeX font commands (\tiny, ..., \normalsize, ..., \Huge) accordingly. + # Otherwise, only set \normalsize, manually. + r"\IfFileExists{scrextend.sty}{", + r" \usepackage[fontsize=%fpt]{scrextend}" % font_size_pt, + r"}{", + r" \renewcommand{\normalsize}{\fontsize{%f}{%f}\selectfont}" + % (font_size_pt, 1.2 * font_size_pt), + r" \normalsize", + r"}", + # Allow pgf.preamble to override the above definitions. + mpl.rcParams["pgf.preamble"], + *([ + r"\ifdefined\pdftexversion\else % non-pdftex case.", + r" \usepackage{fontspec}", + ] + [ + r" \%s{%s}[Path=\detokenize{%s/}]" + % (command, path.name, path.parent.as_posix()) + for command, path in zip( + ["setmainfont", "setsansfont", "setmonofont"], + [pathlib.Path(fm.findfont(family)) + for family in ["serif", "sans\\-serif", "monospace"]] + ) + ] + [r"\fi"] if mpl.rcParams["pgf.rcfonts"] else []), + # Documented as "must come last". + mpl.texmanager._usepackage_if_not_loaded("underscore", option="strings"), + ]) + + +# It's better to use only one unit for all coordinates, since the +# arithmetic in latex seems to produce inaccurate conversions. +latex_pt_to_in = 1. / 72.27 +latex_in_to_pt = 1. / latex_pt_to_in +mpl_pt_to_in = 1. / 72. +mpl_in_to_pt = 1. / mpl_pt_to_in + + +def _tex_escape(text): + r""" + Do some necessary and/or useful substitutions for texts to be included in + LaTeX documents. + """ + return text.replace("\N{MINUS SIGN}", r"\ensuremath{-}") + + +def _writeln(fh, line): + # Ending lines with a % prevents TeX from inserting spurious spaces + # (https://tex.stackexchange.com/questions/7453). + fh.write(line) + fh.write("%\n") + + +def _escape_and_apply_props(s, prop): + """ + Generate a TeX string that renders string *s* with font properties *prop*, + also applying any required escapes to *s*. + """ + commands = [] + + families = {"serif": r"\rmfamily", "sans": r"\sffamily", + "sans-serif": r"\sffamily", "monospace": r"\ttfamily"} + family = prop.get_family()[0] + if family in families: + commands.append(families[family]) + elif not mpl.rcParams["pgf.rcfonts"]: + commands.append(r"\fontfamily{\familydefault}") + elif any(font.name == family for font in fm.fontManager.ttflist): + commands.append( + r"\ifdefined\pdftexversion\else\setmainfont{%s}\rmfamily\fi" % family) + else: + _log.warning("Ignoring unknown font: %s", family) + + size = prop.get_size_in_points() + commands.append(r"\fontsize{%f}{%f}" % (size, size * 1.2)) + + styles = {"normal": r"", "italic": r"\itshape", "oblique": r"\slshape"} + commands.append(styles[prop.get_style()]) + + boldstyles = ["semibold", "demibold", "demi", "bold", "heavy", + "extra bold", "black"] + if prop.get_weight() in boldstyles: + commands.append(r"\bfseries") + + commands.append(r"\selectfont") + return ( + "{" + + "".join(commands) + + r"\catcode`\^=\active\def^{\ifmmode\sp\else\^{}\fi}" + # It should normally be enough to set the catcode of % to 12 ("normal + # character"); this works on TeXLive 2021 but not on 2018, so we just + # make it active too. + + r"\catcode`\%=\active\def%{\%}" + + _tex_escape(s) + + "}" + ) + + +def _metadata_to_str(key, value): + """Convert metadata key/value to a form that hyperref accepts.""" + if isinstance(value, datetime.datetime): + value = _datetime_to_pdf(value) + elif key == 'Trapped': + value = value.name.decode('ascii') + else: + value = str(value) + return f'{key}={{{value}}}' + + +def make_pdf_to_png_converter(): + """Return a function that converts a pdf file to a png file.""" + try: + mpl._get_executable_info("pdftocairo") + except mpl.ExecutableNotFoundError: + pass + else: + return lambda pdffile, pngfile, dpi: subprocess.check_output( + ["pdftocairo", "-singlefile", "-transp", "-png", "-r", "%d" % dpi, + pdffile, os.path.splitext(pngfile)[0]], + stderr=subprocess.STDOUT) + try: + gs_info = mpl._get_executable_info("gs") + except mpl.ExecutableNotFoundError: + pass + else: + return lambda pdffile, pngfile, dpi: subprocess.check_output( + [gs_info.executable, + '-dQUIET', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT', + '-dUseCIEColor', '-dTextAlphaBits=4', + '-dGraphicsAlphaBits=4', '-dDOINTERPOLATE', + '-sDEVICE=pngalpha', '-sOutputFile=%s' % pngfile, + '-r%d' % dpi, pdffile], + stderr=subprocess.STDOUT) + raise RuntimeError("No suitable pdf to png renderer found.") + + +class LatexError(Exception): + def __init__(self, message, latex_output=""): + super().__init__(message) + self.latex_output = latex_output + + def __str__(self): + s, = self.args + if self.latex_output: + s += "\n" + self.latex_output + return s + + +class LatexManager: + """ + The LatexManager opens an instance of the LaTeX application for + determining the metrics of text elements. The LaTeX environment can be + modified by setting fonts and/or a custom preamble in `.rcParams`. + """ + + @staticmethod + def _build_latex_header(): + latex_header = [ + _DOCUMENTCLASS, + # Include TeX program name as a comment for cache invalidation. + # TeX does not allow this to be the first line. + rf"% !TeX program = {mpl.rcParams['pgf.texsystem']}", + # Test whether \includegraphics supports interpolate option. + r"\usepackage{graphicx}", + _get_preamble(), + r"\begin{document}", + r"\typeout{pgf_backend_query_start}", + ] + return "\n".join(latex_header) + + @classmethod + def _get_cached_or_new(cls): + """ + Return the previous LatexManager if the header and tex system did not + change, or a new instance otherwise. + """ + return cls._get_cached_or_new_impl(cls._build_latex_header()) + + @classmethod + @functools.lru_cache(1) + def _get_cached_or_new_impl(cls, header): # Helper for _get_cached_or_new. + return cls() + + def _stdin_writeln(self, s): + if self.latex is None: + self._setup_latex_process() + self.latex.stdin.write(s) + self.latex.stdin.write("\n") + self.latex.stdin.flush() + + def _expect(self, s): + s = list(s) + chars = [] + while True: + c = self.latex.stdout.read(1) + chars.append(c) + if chars[-len(s):] == s: + break + if not c: + self.latex.kill() + self.latex = None + raise LatexError("LaTeX process halted", "".join(chars)) + return "".join(chars) + + def _expect_prompt(self): + return self._expect("\n*") + + def __init__(self): + # create a tmp directory for running latex, register it for deletion + self._tmpdir = TemporaryDirectory() + self.tmpdir = self._tmpdir.name + self._finalize_tmpdir = weakref.finalize(self, self._tmpdir.cleanup) + + # test the LaTeX setup to ensure a clean startup of the subprocess + self._setup_latex_process(expect_reply=False) + stdout, stderr = self.latex.communicate("\n\\makeatletter\\@@end\n") + if self.latex.returncode != 0: + raise LatexError( + f"LaTeX errored (probably missing font or error in preamble) " + f"while processing the following input:\n" + f"{self._build_latex_header()}", + stdout) + self.latex = None # Will be set up on first use. + # Per-instance cache. + self._get_box_metrics = functools.lru_cache(self._get_box_metrics) + + def _setup_latex_process(self, *, expect_reply=True): + # Open LaTeX process for real work; register it for deletion. On + # Windows, we must ensure that the subprocess has quit before being + # able to delete the tmpdir in which it runs; in order to do so, we + # must first `kill()` it, and then `communicate()` with or `wait()` on + # it. + try: + self.latex = subprocess.Popen( + [mpl.rcParams["pgf.texsystem"], "-halt-on-error"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + encoding="utf-8", cwd=self.tmpdir) + except FileNotFoundError as err: + raise RuntimeError( + f"{mpl.rcParams['pgf.texsystem']!r} not found; install it or change " + f"rcParams['pgf.texsystem'] to an available TeX implementation" + ) from err + except OSError as err: + raise RuntimeError( + f"Error starting {mpl.rcParams['pgf.texsystem']!r}") from err + + def finalize_latex(latex): + latex.kill() + try: + latex.communicate() + except RuntimeError: + latex.wait() + + self._finalize_latex = weakref.finalize( + self, finalize_latex, self.latex) + # write header with 'pgf_backend_query_start' token + self._stdin_writeln(self._build_latex_header()) + if expect_reply: # read until 'pgf_backend_query_start' token appears + self._expect("*pgf_backend_query_start") + self._expect_prompt() + + def get_width_height_descent(self, text, prop): + """ + Get the width, total height, and descent (in TeX points) for a text + typeset by the current LaTeX environment. + """ + return self._get_box_metrics(_escape_and_apply_props(text, prop)) + + def _get_box_metrics(self, tex): + """ + Get the width, total height and descent (in TeX points) for a TeX + command's output in the current LaTeX environment. + """ + # This method gets wrapped in __init__ for per-instance caching. + self._stdin_writeln( # Send textbox to TeX & request metrics typeout. + # \sbox doesn't handle catcode assignments inside its argument, + # so repeat the assignment of the catcode of "^" and "%" outside. + r"{\catcode`\^=\active\catcode`\%%=\active\sbox0{%s}" + r"\typeout{\the\wd0,\the\ht0,\the\dp0}}" + % tex) + try: + answer = self._expect_prompt() + except LatexError as err: + # Here and below, use '{}' instead of {!r} to avoid doubling all + # backslashes. + raise ValueError("Error measuring {}\nLaTeX Output:\n{}" + .format(tex, err.latex_output)) from err + try: + # Parse metrics from the answer string. Last line is prompt, and + # next-to-last-line is blank line from \typeout. + width, height, offset = answer.splitlines()[-3].split(",") + except Exception as err: + raise ValueError("Error measuring {}\nLaTeX Output:\n{}" + .format(tex, answer)) from err + w, h, o = float(width[:-2]), float(height[:-2]), float(offset[:-2]) + # The height returned from LaTeX goes from base to top; + # the height Matplotlib expects goes from bottom to top. + return w, h + o, o + + +@functools.lru_cache(1) +def _get_image_inclusion_command(): + man = LatexManager._get_cached_or_new() + man._stdin_writeln( + r"\includegraphics[interpolate=true]{%s}" + # Don't mess with backslashes on Windows. + % cbook._get_data_path("images/matplotlib.png").as_posix()) + try: + man._expect_prompt() + return r"\includegraphics" + except LatexError: + # Discard the broken manager. + LatexManager._get_cached_or_new_impl.cache_clear() + return r"\pgfimage" + + +class RendererPgf(RendererBase): + + def __init__(self, figure, fh): + """ + Create a new PGF renderer that translates any drawing instruction + into text commands to be interpreted in a latex pgfpicture environment. + + Attributes + ---------- + figure : `~matplotlib.figure.Figure` + Matplotlib figure to initialize height, width and dpi from. + fh : file-like + File handle for the output of the drawing commands. + """ + + super().__init__() + self.dpi = figure.dpi + self.fh = fh + self.figure = figure + self.image_counter = 0 + + def draw_markers(self, gc, marker_path, marker_trans, path, trans, + rgbFace=None): + # docstring inherited + + _writeln(self.fh, r"\begin{pgfscope}") + + # convert from display units to in + f = 1. / self.dpi + + # set style and clip + self._print_pgf_clip(gc) + self._print_pgf_path_styles(gc, rgbFace) + + # build marker definition + bl, tr = marker_path.get_extents(marker_trans).get_points() + coords = bl[0] * f, bl[1] * f, tr[0] * f, tr[1] * f + _writeln(self.fh, + r"\pgfsys@defobject{currentmarker}" + r"{\pgfqpoint{%fin}{%fin}}{\pgfqpoint{%fin}{%fin}}{" % coords) + self._print_pgf_path(None, marker_path, marker_trans) + self._pgf_path_draw(stroke=gc.get_linewidth() != 0.0, + fill=rgbFace is not None) + _writeln(self.fh, r"}") + + maxcoord = 16383 / 72.27 * self.dpi # Max dimensions in LaTeX. + clip = (-maxcoord, -maxcoord, maxcoord, maxcoord) + + # draw marker for each vertex + for point, code in path.iter_segments(trans, simplify=False, + clip=clip): + x, y = point[0] * f, point[1] * f + _writeln(self.fh, r"\begin{pgfscope}") + _writeln(self.fh, r"\pgfsys@transformshift{%fin}{%fin}" % (x, y)) + _writeln(self.fh, r"\pgfsys@useobject{currentmarker}{}") + _writeln(self.fh, r"\end{pgfscope}") + + _writeln(self.fh, r"\end{pgfscope}") + + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + _writeln(self.fh, r"\begin{pgfscope}") + # draw the path + self._print_pgf_clip(gc) + self._print_pgf_path_styles(gc, rgbFace) + self._print_pgf_path(gc, path, transform, rgbFace) + self._pgf_path_draw(stroke=gc.get_linewidth() != 0.0, + fill=rgbFace is not None) + _writeln(self.fh, r"\end{pgfscope}") + + # if present, draw pattern on top + if gc.get_hatch(): + _writeln(self.fh, r"\begin{pgfscope}") + self._print_pgf_path_styles(gc, rgbFace) + + # combine clip and path for clipping + self._print_pgf_clip(gc) + self._print_pgf_path(gc, path, transform, rgbFace) + _writeln(self.fh, r"\pgfusepath{clip}") + + # build pattern definition + _writeln(self.fh, + r"\pgfsys@defobject{currentpattern}" + r"{\pgfqpoint{0in}{0in}}{\pgfqpoint{1in}{1in}}{") + _writeln(self.fh, r"\begin{pgfscope}") + _writeln(self.fh, + r"\pgfpathrectangle" + r"{\pgfqpoint{0in}{0in}}{\pgfqpoint{1in}{1in}}") + _writeln(self.fh, r"\pgfusepath{clip}") + scale = mpl.transforms.Affine2D().scale(self.dpi) + self._print_pgf_path(None, gc.get_hatch_path(), scale) + self._pgf_path_draw(stroke=True) + _writeln(self.fh, r"\end{pgfscope}") + _writeln(self.fh, r"}") + # repeat pattern, filling the bounding rect of the path + f = 1. / self.dpi + (xmin, ymin), (xmax, ymax) = \ + path.get_extents(transform).get_points() + xmin, xmax = f * xmin, f * xmax + ymin, ymax = f * ymin, f * ymax + repx, repy = math.ceil(xmax - xmin), math.ceil(ymax - ymin) + _writeln(self.fh, + r"\pgfsys@transformshift{%fin}{%fin}" % (xmin, ymin)) + for iy in range(repy): + for ix in range(repx): + _writeln(self.fh, r"\pgfsys@useobject{currentpattern}{}") + _writeln(self.fh, r"\pgfsys@transformshift{1in}{0in}") + _writeln(self.fh, r"\pgfsys@transformshift{-%din}{0in}" % repx) + _writeln(self.fh, r"\pgfsys@transformshift{0in}{1in}") + + _writeln(self.fh, r"\end{pgfscope}") + + def _print_pgf_clip(self, gc): + f = 1. / self.dpi + # check for clip box + bbox = gc.get_clip_rectangle() + if bbox: + p1, p2 = bbox.get_points() + w, h = p2 - p1 + coords = p1[0] * f, p1[1] * f, w * f, h * f + _writeln(self.fh, + r"\pgfpathrectangle" + r"{\pgfqpoint{%fin}{%fin}}{\pgfqpoint{%fin}{%fin}}" + % coords) + _writeln(self.fh, r"\pgfusepath{clip}") + + # check for clip path + clippath, clippath_trans = gc.get_clip_path() + if clippath is not None: + self._print_pgf_path(gc, clippath, clippath_trans) + _writeln(self.fh, r"\pgfusepath{clip}") + + def _print_pgf_path_styles(self, gc, rgbFace): + # cap style + capstyles = {"butt": r"\pgfsetbuttcap", + "round": r"\pgfsetroundcap", + "projecting": r"\pgfsetrectcap"} + _writeln(self.fh, capstyles[gc.get_capstyle()]) + + # join style + joinstyles = {"miter": r"\pgfsetmiterjoin", + "round": r"\pgfsetroundjoin", + "bevel": r"\pgfsetbeveljoin"} + _writeln(self.fh, joinstyles[gc.get_joinstyle()]) + + # filling + has_fill = rgbFace is not None + + if gc.get_forced_alpha(): + fillopacity = strokeopacity = gc.get_alpha() + else: + strokeopacity = gc.get_rgb()[3] + fillopacity = rgbFace[3] if has_fill and len(rgbFace) > 3 else 1.0 + + if has_fill: + _writeln(self.fh, + r"\definecolor{currentfill}{rgb}{%f,%f,%f}" + % tuple(rgbFace[:3])) + _writeln(self.fh, r"\pgfsetfillcolor{currentfill}") + if has_fill and fillopacity != 1.0: + _writeln(self.fh, r"\pgfsetfillopacity{%f}" % fillopacity) + + # linewidth and color + lw = gc.get_linewidth() * mpl_pt_to_in * latex_in_to_pt + stroke_rgba = gc.get_rgb() + _writeln(self.fh, r"\pgfsetlinewidth{%fpt}" % lw) + _writeln(self.fh, + r"\definecolor{currentstroke}{rgb}{%f,%f,%f}" + % stroke_rgba[:3]) + _writeln(self.fh, r"\pgfsetstrokecolor{currentstroke}") + if strokeopacity != 1.0: + _writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % strokeopacity) + + # line style + dash_offset, dash_list = gc.get_dashes() + if dash_list is None: + _writeln(self.fh, r"\pgfsetdash{}{0pt}") + else: + _writeln(self.fh, + r"\pgfsetdash{%s}{%fpt}" + % ("".join(r"{%fpt}" % dash for dash in dash_list), + dash_offset)) + + def _print_pgf_path(self, gc, path, transform, rgbFace=None): + f = 1. / self.dpi + # check for clip box / ignore clip for filled paths + bbox = gc.get_clip_rectangle() if gc else None + maxcoord = 16383 / 72.27 * self.dpi # Max dimensions in LaTeX. + if bbox and (rgbFace is None): + p1, p2 = bbox.get_points() + clip = (max(p1[0], -maxcoord), max(p1[1], -maxcoord), + min(p2[0], maxcoord), min(p2[1], maxcoord)) + else: + clip = (-maxcoord, -maxcoord, maxcoord, maxcoord) + # build path + for points, code in path.iter_segments(transform, clip=clip): + if code == Path.MOVETO: + x, y = tuple(points) + _writeln(self.fh, + r"\pgfpathmoveto{\pgfqpoint{%fin}{%fin}}" % + (f * x, f * y)) + elif code == Path.CLOSEPOLY: + _writeln(self.fh, r"\pgfpathclose") + elif code == Path.LINETO: + x, y = tuple(points) + _writeln(self.fh, + r"\pgfpathlineto{\pgfqpoint{%fin}{%fin}}" % + (f * x, f * y)) + elif code == Path.CURVE3: + cx, cy, px, py = tuple(points) + coords = cx * f, cy * f, px * f, py * f + _writeln(self.fh, + r"\pgfpathquadraticcurveto" + r"{\pgfqpoint{%fin}{%fin}}{\pgfqpoint{%fin}{%fin}}" + % coords) + elif code == Path.CURVE4: + c1x, c1y, c2x, c2y, px, py = tuple(points) + coords = c1x * f, c1y * f, c2x * f, c2y * f, px * f, py * f + _writeln(self.fh, + r"\pgfpathcurveto" + r"{\pgfqpoint{%fin}{%fin}}" + r"{\pgfqpoint{%fin}{%fin}}" + r"{\pgfqpoint{%fin}{%fin}}" + % coords) + + # apply pgf decorators + sketch_params = gc.get_sketch_params() if gc else None + if sketch_params is not None: + # Only "length" directly maps to "segment length" in PGF's API. + # PGF uses "amplitude" to pass the combined deviation in both x- + # and y-direction, while matplotlib only varies the length of the + # wiggle along the line ("randomness" and "length" parameters) + # and has a separate "scale" argument for the amplitude. + # -> Use "randomness" as PRNG seed to allow the user to force the + # same shape on multiple sketched lines + scale, length, randomness = sketch_params + if scale is not None: + # make matplotlib and PGF rendering visually similar + length *= 0.5 + scale *= 2 + # PGF guarantees that repeated loading is a no-op + _writeln(self.fh, r"\usepgfmodule{decorations}") + _writeln(self.fh, r"\usepgflibrary{decorations.pathmorphing}") + _writeln(self.fh, r"\pgfkeys{/pgf/decoration/.cd, " + f"segment length = {(length * f):f}in, " + f"amplitude = {(scale * f):f}in}}") + _writeln(self.fh, f"\\pgfmathsetseed{{{int(randomness)}}}") + _writeln(self.fh, r"\pgfdecoratecurrentpath{random steps}") + + def _pgf_path_draw(self, stroke=True, fill=False): + actions = [] + if stroke: + actions.append("stroke") + if fill: + actions.append("fill") + _writeln(self.fh, r"\pgfusepath{%s}" % ",".join(actions)) + + def option_scale_image(self): + # docstring inherited + return True + + def option_image_nocomposite(self): + # docstring inherited + return not mpl.rcParams['image.composite_image'] + + def draw_image(self, gc, x, y, im, transform=None): + # docstring inherited + + h, w = im.shape[:2] + if w == 0 or h == 0: + return + + if not os.path.exists(getattr(self.fh, "name", "")): + raise ValueError( + "streamed pgf-code does not support raster graphics, consider " + "using the pgf-to-pdf option") + + # save the images to png files + path = pathlib.Path(self.fh.name) + fname_img = "%s-img%d.png" % (path.stem, self.image_counter) + Image.fromarray(im[::-1]).save(path.parent / fname_img) + self.image_counter += 1 + + # reference the image in the pgf picture + _writeln(self.fh, r"\begin{pgfscope}") + self._print_pgf_clip(gc) + f = 1. / self.dpi # from display coords to inch + if transform is None: + _writeln(self.fh, + r"\pgfsys@transformshift{%fin}{%fin}" % (x * f, y * f)) + w, h = w * f, h * f + else: + tr1, tr2, tr3, tr4, tr5, tr6 = transform.frozen().to_values() + _writeln(self.fh, + r"\pgfsys@transformcm{%f}{%f}{%f}{%f}{%fin}{%fin}" % + (tr1 * f, tr2 * f, tr3 * f, tr4 * f, + (tr5 + x) * f, (tr6 + y) * f)) + w = h = 1 # scale is already included in the transform + interp = str(transform is None).lower() # interpolation in PDF reader + _writeln(self.fh, + r"\pgftext[left,bottom]" + r"{%s[interpolate=%s,width=%fin,height=%fin]{%s}}" % + (_get_image_inclusion_command(), + interp, w, h, fname_img)) + _writeln(self.fh, r"\end{pgfscope}") + + def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): + # docstring inherited + self.draw_text(gc, x, y, s, prop, angle, ismath="TeX", mtext=mtext) + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + + # prepare string for tex + s = _escape_and_apply_props(s, prop) + + _writeln(self.fh, r"\begin{pgfscope}") + self._print_pgf_clip(gc) + + alpha = gc.get_alpha() + if alpha != 1.0: + _writeln(self.fh, r"\pgfsetfillopacity{%f}" % alpha) + _writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % alpha) + rgb = tuple(gc.get_rgb())[:3] + _writeln(self.fh, r"\definecolor{textcolor}{rgb}{%f,%f,%f}" % rgb) + _writeln(self.fh, r"\pgfsetstrokecolor{textcolor}") + _writeln(self.fh, r"\pgfsetfillcolor{textcolor}") + s = r"\color{textcolor}" + s + + dpi = self.figure.dpi + text_args = [] + if mtext and ( + (angle == 0 or + mtext.get_rotation_mode() == "anchor") and + mtext.get_verticalalignment() != "center_baseline"): + # if text anchoring can be supported, get the original coordinates + # and add alignment information + pos = mtext.get_unitless_position() + x, y = mtext.get_transform().transform(pos) + halign = {"left": "left", "right": "right", "center": ""} + valign = {"top": "top", "bottom": "bottom", + "baseline": "base", "center": ""} + text_args.extend([ + f"x={x/dpi:f}in", + f"y={y/dpi:f}in", + halign[mtext.get_horizontalalignment()], + valign[mtext.get_verticalalignment()], + ]) + else: + # if not, use the text layout provided by Matplotlib. + text_args.append(f"x={x/dpi:f}in, y={y/dpi:f}in, left, base") + + if angle != 0: + text_args.append("rotate=%f" % angle) + + _writeln(self.fh, r"\pgftext[%s]{%s}" % (",".join(text_args), s)) + _writeln(self.fh, r"\end{pgfscope}") + + def get_text_width_height_descent(self, s, prop, ismath): + # docstring inherited + # get text metrics in units of latex pt, convert to display units + w, h, d = (LatexManager._get_cached_or_new() + .get_width_height_descent(s, prop)) + # TODO: this should be latex_pt_to_in instead of mpl_pt_to_in + # but having a little bit more space around the text looks better, + # plus the bounding box reported by LaTeX is VERY narrow + f = mpl_pt_to_in * self.dpi + return w * f, h * f, d * f + + def flipy(self): + # docstring inherited + return False + + def get_canvas_width_height(self): + # docstring inherited + return (self.figure.get_figwidth() * self.dpi, + self.figure.get_figheight() * self.dpi) + + def points_to_pixels(self, points): + # docstring inherited + return points * mpl_pt_to_in * self.dpi + + +class FigureCanvasPgf(FigureCanvasBase): + filetypes = {"pgf": "LaTeX PGF picture", + "pdf": "LaTeX compiled PGF picture", + "png": "Portable Network Graphics", } + + def get_default_filetype(self): + return 'pdf' + + def _print_pgf_to_fh(self, fh, *, bbox_inches_restore=None): + + header_text = """%% Creator: Matplotlib, PGF backend +%% +%% To include the figure in your LaTeX document, write +%% \\input{.pgf} +%% +%% Make sure the required packages are loaded in your preamble +%% \\usepackage{pgf} +%% +%% Also ensure that all the required font packages are loaded; for instance, +%% the lmodern package is sometimes necessary when using math font. +%% \\usepackage{lmodern} +%% +%% Figures using additional raster images can only be included by \\input if +%% they are in the same directory as the main LaTeX file. For loading figures +%% from other directories you can use the `import` package +%% \\usepackage{import} +%% +%% and then include the figures with +%% \\import{}{.pgf} +%% +""" + + # append the preamble used by the backend as a comment for debugging + header_info_preamble = ["%% Matplotlib used the following preamble"] + for line in _get_preamble().splitlines(): + header_info_preamble.append("%% " + line) + header_info_preamble.append("%%") + header_info_preamble = "\n".join(header_info_preamble) + + # get figure size in inch + w, h = self.figure.get_figwidth(), self.figure.get_figheight() + dpi = self.figure.dpi + + # create pgfpicture environment and write the pgf code + fh.write(header_text) + fh.write(header_info_preamble) + fh.write("\n") + _writeln(fh, r"\begingroup") + _writeln(fh, r"\makeatletter") + _writeln(fh, r"\begin{pgfpicture}") + _writeln(fh, + r"\pgfpathrectangle{\pgfpointorigin}{\pgfqpoint{%fin}{%fin}}" + % (w, h)) + _writeln(fh, r"\pgfusepath{use as bounding box, clip}") + renderer = MixedModeRenderer(self.figure, w, h, dpi, + RendererPgf(self.figure, fh), + bbox_inches_restore=bbox_inches_restore) + self.figure.draw(renderer) + + # end the pgfpicture environment + _writeln(fh, r"\end{pgfpicture}") + _writeln(fh, r"\makeatother") + _writeln(fh, r"\endgroup") + + def print_pgf(self, fname_or_fh, **kwargs): + """ + Output pgf macros for drawing the figure so it can be included and + rendered in latex documents. + """ + with cbook.open_file_cm(fname_or_fh, "w", encoding="utf-8") as file: + if not cbook.file_requires_unicode(file): + file = codecs.getwriter("utf-8")(file) + self._print_pgf_to_fh(file, **kwargs) + + def print_pdf(self, fname_or_fh, *, metadata=None, **kwargs): + """Use LaTeX to compile a pgf generated figure to pdf.""" + w, h = self.figure.get_size_inches() + + info_dict = _create_pdf_info_dict('pgf', metadata or {}) + pdfinfo = ','.join( + _metadata_to_str(k, v) for k, v in info_dict.items()) + + # print figure to pgf and compile it with latex + with TemporaryDirectory() as tmpdir: + tmppath = pathlib.Path(tmpdir) + self.print_pgf(tmppath / "figure.pgf", **kwargs) + (tmppath / "figure.tex").write_text( + "\n".join([ + _DOCUMENTCLASS, + r"\usepackage[pdfinfo={%s}]{hyperref}" % pdfinfo, + r"\usepackage[papersize={%fin,%fin}, margin=0in]{geometry}" + % (w, h), + r"\usepackage{pgf}", + _get_preamble(), + r"\begin{document}", + r"\centering", + r"\input{figure.pgf}", + r"\end{document}", + ]), encoding="utf-8") + texcommand = mpl.rcParams["pgf.texsystem"] + cbook._check_and_log_subprocess( + [texcommand, "-interaction=nonstopmode", "-halt-on-error", + "figure.tex"], _log, cwd=tmpdir) + with ((tmppath / "figure.pdf").open("rb") as orig, + cbook.open_file_cm(fname_or_fh, "wb") as dest): + shutil.copyfileobj(orig, dest) # copy file contents to target + + def print_png(self, fname_or_fh, **kwargs): + """Use LaTeX to compile a pgf figure to pdf and convert it to png.""" + converter = make_pdf_to_png_converter() + with TemporaryDirectory() as tmpdir: + tmppath = pathlib.Path(tmpdir) + pdf_path = tmppath / "figure.pdf" + png_path = tmppath / "figure.png" + self.print_pdf(pdf_path, **kwargs) + converter(pdf_path, png_path, dpi=self.figure.dpi) + with (png_path.open("rb") as orig, + cbook.open_file_cm(fname_or_fh, "wb") as dest): + shutil.copyfileobj(orig, dest) # copy file contents to target + + def get_renderer(self): + return RendererPgf(self.figure, None) + + def draw(self): + self.figure.draw_without_rendering() + return super().draw() + + +FigureManagerPgf = FigureManagerBase + + +@_Backend.export +class _BackendPgf(_Backend): + FigureCanvas = FigureCanvasPgf + + +class PdfPages: + """ + A multi-page PDF file using the pgf backend + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> # Initialize: + >>> with PdfPages('foo.pdf') as pdf: + ... # As many times as you like, create a figure fig and save it: + ... fig = plt.figure() + ... pdf.savefig(fig) + ... # When no figure is specified the current figure is saved + ... pdf.savefig() + """ + + def __init__(self, filename, *, metadata=None): + """ + Create a new PdfPages object. + + Parameters + ---------- + filename : str or path-like + Plots using `PdfPages.savefig` will be written to a file at this + location. Any older file with the same name is overwritten. + + metadata : dict, optional + Information dictionary object (see PDF reference section 10.2.1 + 'Document Information Dictionary'), e.g.: + ``{'Creator': 'My software', 'Author': 'Me', 'Title': 'Awesome'}``. + + The standard keys are 'Title', 'Author', 'Subject', 'Keywords', + 'Creator', 'Producer', 'CreationDate', 'ModDate', and + 'Trapped'. Values have been predefined for 'Creator', 'Producer' + and 'CreationDate'. They can be removed by setting them to `None`. + + Note that some versions of LaTeX engines may ignore the 'Producer' + key and set it to themselves. + """ + self._output_name = filename + self._n_figures = 0 + self._metadata = (metadata or {}).copy() + self._info_dict = _create_pdf_info_dict('pgf', self._metadata) + self._file = BytesIO() + + def _write_header(self, width_inches, height_inches): + pdfinfo = ','.join( + _metadata_to_str(k, v) for k, v in self._info_dict.items()) + latex_header = "\n".join([ + _DOCUMENTCLASS, + r"\usepackage[pdfinfo={%s}]{hyperref}" % pdfinfo, + r"\usepackage[papersize={%fin,%fin}, margin=0in]{geometry}" + % (width_inches, height_inches), + r"\usepackage{pgf}", + _get_preamble(), + r"\setlength{\parindent}{0pt}", + r"\begin{document}%", + ]) + self._file.write(latex_header.encode('utf-8')) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): + """ + Finalize this object, running LaTeX in a temporary directory + and moving the final pdf file to *filename*. + """ + self._file.write(rb'\end{document}\n') + if self._n_figures > 0: + self._run_latex() + self._file.close() + + def _run_latex(self): + texcommand = mpl.rcParams["pgf.texsystem"] + with TemporaryDirectory() as tmpdir: + tex_source = pathlib.Path(tmpdir, "pdf_pages.tex") + tex_source.write_bytes(self._file.getvalue()) + cbook._check_and_log_subprocess( + [texcommand, "-interaction=nonstopmode", "-halt-on-error", + tex_source], + _log, cwd=tmpdir) + shutil.move(tex_source.with_suffix(".pdf"), self._output_name) + + def savefig(self, figure=None, **kwargs): + """ + Save a `.Figure` to this file as a new page. + + Any other keyword arguments are passed to `~.Figure.savefig`. + + Parameters + ---------- + figure : `.Figure` or int, default: the active figure + The figure, or index of the figure, that is saved to the file. + """ + if not isinstance(figure, Figure): + if figure is None: + manager = Gcf.get_active() + else: + manager = Gcf.get_fig_manager(figure) + if manager is None: + raise ValueError(f"No figure {figure}") + figure = manager.canvas.figure + + width, height = figure.get_size_inches() + if self._n_figures == 0: + self._write_header(width, height) + else: + # \pdfpagewidth and \pdfpageheight exist on pdftex, xetex, and + # luatex<0.85; they were renamed to \pagewidth and \pageheight + # on luatex>=0.85. + self._file.write( + rb'\newpage' + rb'\ifdefined\pdfpagewidth\pdfpagewidth\else\pagewidth\fi=%fin' + rb'\ifdefined\pdfpageheight\pdfpageheight\else\pageheight\fi=%fin' + b'%%\n' % (width, height) + ) + figure.savefig(self._file, format="pgf", backend="pgf", **kwargs) + self._n_figures += 1 + + def get_pagecount(self): + """Return the current number of pages in the multipage pdf file.""" + return self._n_figures diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_ps.py b/lib/python3.12/site-packages/matplotlib/backends/backend_ps.py new file mode 100644 index 0000000000000000000000000000000000000000..f1f914ae54205b9b77ae1db910885770cc9bfdec --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_ps.py @@ -0,0 +1,1481 @@ +""" +A PostScript backend, which can produce both PostScript .ps and .eps. +""" + +import bisect +import codecs +import datetime +from enum import Enum +import functools +from io import StringIO +import itertools +import logging +import math +import os +import pathlib +import shutil +import struct +from tempfile import TemporaryDirectory +import textwrap +import time + +import fontTools +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook, _path, _text_helpers +from matplotlib._afm import AFM +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) +from matplotlib.cbook import is_writable_file_like, file_requires_unicode +from matplotlib.font_manager import get_font +from matplotlib.ft2font import LoadFlags +from matplotlib._mathtext_data import uni2type1 +from matplotlib.path import Path +from matplotlib.texmanager import TexManager +from matplotlib.transforms import Affine2D +from matplotlib.backends.backend_mixed import MixedModeRenderer +from . import _backend_pdf_ps + + +_log = logging.getLogger(__name__) +debugPS = False + + +papersize = {'letter': (8.5, 11), + 'legal': (8.5, 14), + 'ledger': (11, 17), + 'a0': (33.11, 46.81), + 'a1': (23.39, 33.11), + 'a2': (16.54, 23.39), + 'a3': (11.69, 16.54), + 'a4': (8.27, 11.69), + 'a5': (5.83, 8.27), + 'a6': (4.13, 5.83), + 'a7': (2.91, 4.13), + 'a8': (2.05, 2.91), + 'a9': (1.46, 2.05), + 'a10': (1.02, 1.46), + 'b0': (40.55, 57.32), + 'b1': (28.66, 40.55), + 'b2': (20.27, 28.66), + 'b3': (14.33, 20.27), + 'b4': (10.11, 14.33), + 'b5': (7.16, 10.11), + 'b6': (5.04, 7.16), + 'b7': (3.58, 5.04), + 'b8': (2.51, 3.58), + 'b9': (1.76, 2.51), + 'b10': (1.26, 1.76)} + + +def _nums_to_str(*args, sep=" "): + return sep.join(f"{arg:1.3f}".rstrip("0").rstrip(".") for arg in args) + + +def _move_path_to_path_or_stream(src, dst): + """ + Move the contents of file at *src* to path-or-filelike *dst*. + + If *dst* is a path, the metadata of *src* are *not* copied. + """ + if is_writable_file_like(dst): + fh = (open(src, encoding='latin-1') + if file_requires_unicode(dst) + else open(src, 'rb')) + with fh: + shutil.copyfileobj(fh, dst) + else: + shutil.move(src, dst, copy_function=shutil.copyfile) + + +def _font_to_ps_type3(font_path, chars): + """ + Subset *chars* from the font at *font_path* into a Type 3 font. + + Parameters + ---------- + font_path : path-like + Path to the font to be subsetted. + chars : str + The characters to include in the subsetted font. + + Returns + ------- + str + The string representation of a Type 3 font, which can be included + verbatim into a PostScript file. + """ + font = get_font(font_path, hinting_factor=1) + glyph_ids = [font.get_char_index(c) for c in chars] + + preamble = """\ +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /{font_name} def +/PaintType 0 def +/FontMatrix [{inv_units_per_em} 0 0 {inv_units_per_em} 0 0] def +/FontBBox [{bbox}] def +/FontType 3 def +/Encoding [{encoding}] def +/CharStrings {num_glyphs} dict dup begin +/.notdef 0 def +""".format(font_name=font.postscript_name, + inv_units_per_em=1 / font.units_per_EM, + bbox=" ".join(map(str, font.bbox)), + encoding=" ".join(f"/{font.get_glyph_name(glyph_id)}" + for glyph_id in glyph_ids), + num_glyphs=len(glyph_ids) + 1) + postamble = """ +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} _d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} _d + +FontName currentdict end definefont pop +""" + + entries = [] + for glyph_id in glyph_ids: + g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + v, c = font.get_path() + entries.append( + "/%(name)s{%(bbox)s sc\n" % { + "name": font.get_glyph_name(glyph_id), + "bbox": " ".join(map(str, [g.horiAdvance, 0, *g.bbox])), + } + + _path.convert_to_string( + # Convert back to TrueType's internal units (1/64's). + # (Other dimensions are already in these units.) + Path(v * 64, c), None, None, False, None, 0, + # No code for quad Beziers triggers auto-conversion to cubics. + # Drop intermediate closepolys (relying on the outline + # decomposer always explicitly moving to the closing point + # first). + [b"m", b"l", b"", b"c", b""], True).decode("ascii") + + "ce} _d" + ) + + return preamble + "\n".join(entries) + postamble + + +def _font_to_ps_type42(font_path, chars, fh): + """ + Subset *chars* from the font at *font_path* into a Type 42 font at *fh*. + + Parameters + ---------- + font_path : path-like + Path to the font to be subsetted. + chars : str + The characters to include in the subsetted font. + fh : file-like + Where to write the font. + """ + subset_str = ''.join(chr(c) for c in chars) + _log.debug("SUBSET %s characters: %s", font_path, subset_str) + try: + kw = {} + # fix this once we support loading more fonts from a collection + # https://github.com/matplotlib/matplotlib/issues/3135#issuecomment-571085541 + if font_path.endswith('.ttc'): + kw['fontNumber'] = 0 + with (fontTools.ttLib.TTFont(font_path, **kw) as font, + _backend_pdf_ps.get_glyphs_subset(font_path, subset_str) as subset): + fontdata = _backend_pdf_ps.font_as_file(subset).getvalue() + _log.debug( + "SUBSET %s %d -> %d", font_path, os.stat(font_path).st_size, + len(fontdata) + ) + fh.write(_serialize_type42(font, subset, fontdata)) + except RuntimeError: + _log.warning( + "The PostScript backend does not currently support the selected font (%s).", + font_path) + raise + + +def _serialize_type42(font, subset, fontdata): + """ + Output a PostScript Type-42 format representation of font + + Parameters + ---------- + font : fontTools.ttLib.ttFont.TTFont + The original font object + subset : fontTools.ttLib.ttFont.TTFont + The subset font object + fontdata : bytes + The raw font data in TTF format + + Returns + ------- + str + The Type-42 formatted font + """ + version, breakpoints = _version_and_breakpoints(font.get('loca'), fontdata) + post = font['post'] + name = font['name'] + chars = _generate_charstrings(subset) + sfnts = _generate_sfnts(fontdata, subset, breakpoints) + return textwrap.dedent(f""" + %%!PS-TrueTypeFont-{version[0]}.{version[1]}-{font['head'].fontRevision:.7f} + 10 dict begin + /FontType 42 def + /FontMatrix [1 0 0 1 0 0] def + /FontName /{name.getDebugName(6)} def + /FontInfo 7 dict dup begin + /FullName ({name.getDebugName(4)}) def + /FamilyName ({name.getDebugName(1)}) def + /Version ({name.getDebugName(5)}) def + /ItalicAngle {post.italicAngle} def + /isFixedPitch {'true' if post.isFixedPitch else 'false'} def + /UnderlinePosition {post.underlinePosition} def + /UnderlineThickness {post.underlineThickness} def + end readonly def + /Encoding StandardEncoding def + /FontBBox [{_nums_to_str(*_bounds(font))}] def + /PaintType 0 def + /CIDMap 0 def + {chars} + {sfnts} + FontName currentdict end definefont pop + """) + + +def _version_and_breakpoints(loca, fontdata): + """ + Read the version number of the font and determine sfnts breakpoints. + + When a TrueType font file is written as a Type 42 font, it has to be + broken into substrings of at most 65535 bytes. These substrings must + begin at font table boundaries or glyph boundaries in the glyf table. + This function determines all possible breakpoints and it is the caller's + responsibility to do the splitting. + + Helper function for _font_to_ps_type42. + + Parameters + ---------- + loca : fontTools.ttLib._l_o_c_a.table__l_o_c_a or None + The loca table of the font if available + fontdata : bytes + The raw data of the font + + Returns + ------- + version : tuple[int, int] + A 2-tuple of the major version number and minor version number. + breakpoints : list[int] + The breakpoints is a sorted list of offsets into fontdata; if loca is not + available, just the table boundaries. + """ + v1, v2, numTables = struct.unpack('>3h', fontdata[:6]) + version = (v1, v2) + + tables = {} + for i in range(numTables): + tag, _, offset, _ = struct.unpack('>4sIII', fontdata[12 + i*16:12 + (i+1)*16]) + tables[tag.decode('ascii')] = offset + if loca is not None: + glyf_breakpoints = {tables['glyf'] + offset for offset in loca.locations[:-1]} + else: + glyf_breakpoints = set() + breakpoints = sorted({*tables.values(), *glyf_breakpoints, len(fontdata)}) + + return version, breakpoints + + +def _bounds(font): + """ + Compute the font bounding box, as if all glyphs were written + at the same start position. + + Helper function for _font_to_ps_type42. + + Parameters + ---------- + font : fontTools.ttLib.ttFont.TTFont + The font + + Returns + ------- + tuple + (xMin, yMin, xMax, yMax) of the combined bounding box + of all the glyphs in the font + """ + gs = font.getGlyphSet(False) + pen = fontTools.pens.boundsPen.BoundsPen(gs) + for name in gs.keys(): + gs[name].draw(pen) + return pen.bounds or (0, 0, 0, 0) + + +def _generate_charstrings(font): + """ + Transform font glyphs into CharStrings + + Helper function for _font_to_ps_type42. + + Parameters + ---------- + font : fontTools.ttLib.ttFont.TTFont + The font + + Returns + ------- + str + A definition of the CharStrings dictionary in PostScript + """ + go = font.getGlyphOrder() + s = f'/CharStrings {len(go)} dict dup begin\n' + for i, name in enumerate(go): + s += f'/{name} {i} def\n' + s += 'end readonly def' + return s + + +def _generate_sfnts(fontdata, font, breakpoints): + """ + Transform font data into PostScript sfnts format. + + Helper function for _font_to_ps_type42. + + Parameters + ---------- + fontdata : bytes + The raw data of the font + font : fontTools.ttLib.ttFont.TTFont + The fontTools font object + breakpoints : list + Sorted offsets of possible breakpoints + + Returns + ------- + str + The sfnts array for the font definition, consisting + of hex-encoded strings in PostScript format + """ + s = '/sfnts[' + pos = 0 + while pos < len(fontdata): + i = bisect.bisect_left(breakpoints, pos + 65534) + newpos = breakpoints[i-1] + if newpos <= pos: + # have to accept a larger string + newpos = breakpoints[-1] + s += f'<{fontdata[pos:newpos].hex()}00>' # Always NUL terminate. + pos = newpos + s += ']def' + return '\n'.join(s[i:i+100] for i in range(0, len(s), 100)) + + +def _log_if_debug_on(meth): + """ + Wrap `RendererPS` method *meth* to emit a PS comment with the method name, + if the global flag `debugPS` is set. + """ + @functools.wraps(meth) + def wrapper(self, *args, **kwargs): + if debugPS: + self._pswriter.write(f"% {meth.__name__}\n") + return meth(self, *args, **kwargs) + + return wrapper + + +class RendererPS(_backend_pdf_ps.RendererPDFPSBase): + """ + The renderer handles all the drawing primitives using a graphics + context instance that controls the colors/styles. + """ + + _afm_font_dir = cbook._get_data_path("fonts/afm") + _use_afm_rc_name = "ps.useafm" + + def __init__(self, width, height, pswriter, imagedpi=72): + # Although postscript itself is dpi independent, we need to inform the + # image code about a requested dpi to generate high resolution images + # and them scale them before embedding them. + super().__init__(width, height) + self._pswriter = pswriter + if mpl.rcParams['text.usetex']: + self.textcnt = 0 + self.psfrag = [] + self.imagedpi = imagedpi + + # current renderer state (None=uninitialised) + self.color = None + self.linewidth = None + self.linejoin = None + self.linecap = None + self.linedash = None + self.fontname = None + self.fontsize = None + self._hatches = {} + self.image_magnification = imagedpi / 72 + self._clip_paths = {} + self._path_collection_id = 0 + + self._character_tracker = _backend_pdf_ps.CharacterTracker() + self._logwarn_once = functools.cache(_log.warning) + + def _is_transparent(self, rgb_or_rgba): + if rgb_or_rgba is None: + return True # Consistent with rgbFace semantics. + elif len(rgb_or_rgba) == 4: + if rgb_or_rgba[3] == 0: + return True + if rgb_or_rgba[3] != 1: + self._logwarn_once( + "The PostScript backend does not support transparency; " + "partially transparent artists will be rendered opaque.") + return False + else: # len() == 3. + return False + + def set_color(self, r, g, b, store=True): + if (r, g, b) != self.color: + self._pswriter.write(f"{_nums_to_str(r)} setgray\n" + if r == g == b else + f"{_nums_to_str(r, g, b)} setrgbcolor\n") + if store: + self.color = (r, g, b) + + def set_linewidth(self, linewidth, store=True): + linewidth = float(linewidth) + if linewidth != self.linewidth: + self._pswriter.write(f"{_nums_to_str(linewidth)} setlinewidth\n") + if store: + self.linewidth = linewidth + + @staticmethod + def _linejoin_cmd(linejoin): + # Support for directly passing integer values is for backcompat. + linejoin = {'miter': 0, 'round': 1, 'bevel': 2, 0: 0, 1: 1, 2: 2}[ + linejoin] + return f"{linejoin:d} setlinejoin\n" + + def set_linejoin(self, linejoin, store=True): + if linejoin != self.linejoin: + self._pswriter.write(self._linejoin_cmd(linejoin)) + if store: + self.linejoin = linejoin + + @staticmethod + def _linecap_cmd(linecap): + # Support for directly passing integer values is for backcompat. + linecap = {'butt': 0, 'round': 1, 'projecting': 2, 0: 0, 1: 1, 2: 2}[ + linecap] + return f"{linecap:d} setlinecap\n" + + def set_linecap(self, linecap, store=True): + if linecap != self.linecap: + self._pswriter.write(self._linecap_cmd(linecap)) + if store: + self.linecap = linecap + + def set_linedash(self, offset, seq, store=True): + if self.linedash is not None: + oldo, oldseq = self.linedash + if np.array_equal(seq, oldseq) and oldo == offset: + return + + self._pswriter.write(f"[{_nums_to_str(*seq)}] {_nums_to_str(offset)} setdash\n" + if seq is not None and len(seq) else + "[] 0 setdash\n") + if store: + self.linedash = (offset, seq) + + def set_font(self, fontname, fontsize, store=True): + if (fontname, fontsize) != (self.fontname, self.fontsize): + self._pswriter.write(f"/{fontname} {fontsize:1.3f} selectfont\n") + if store: + self.fontname = fontname + self.fontsize = fontsize + + def create_hatch(self, hatch, linewidth): + sidelen = 72 + if hatch in self._hatches: + return self._hatches[hatch] + name = 'H%d' % len(self._hatches) + pageheight = self.height * 72 + self._pswriter.write(f"""\ + << /PatternType 1 + /PaintType 2 + /TilingType 2 + /BBox[0 0 {sidelen:d} {sidelen:d}] + /XStep {sidelen:d} + /YStep {sidelen:d} + + /PaintProc {{ + pop + {linewidth:g} setlinewidth +{self._convert_path(Path.hatch(hatch), Affine2D().scale(sidelen), simplify=False)} + gsave + fill + grestore + stroke + }} bind + >> + matrix + 0 {pageheight:g} translate + makepattern + /{name} exch def +""") + self._hatches[hatch] = name + return name + + def get_image_magnification(self): + """ + Get the factor by which to magnify images passed to draw_image. + Allows a backend to have images at a different resolution to other + artists. + """ + return self.image_magnification + + def _convert_path(self, path, transform, clip=False, simplify=None): + if clip: + clip = (0.0, 0.0, self.width * 72.0, self.height * 72.0) + else: + clip = None + return _path.convert_to_string( + path, transform, clip, simplify, None, + 6, [b"m", b"l", b"", b"c", b"cl"], True).decode("ascii") + + def _get_clip_cmd(self, gc): + clip = [] + rect = gc.get_clip_rectangle() + if rect is not None: + clip.append(f"{_nums_to_str(*rect.p0, *rect.size)} rectclip\n") + path, trf = gc.get_clip_path() + if path is not None: + key = (path, id(trf)) + custom_clip_cmd = self._clip_paths.get(key) + if custom_clip_cmd is None: + custom_clip_cmd = "c%d" % len(self._clip_paths) + self._pswriter.write(f"""\ +/{custom_clip_cmd} {{ +{self._convert_path(path, trf, simplify=False)} +clip +newpath +}} bind def +""") + self._clip_paths[key] = custom_clip_cmd + clip.append(f"{custom_clip_cmd}\n") + return "".join(clip) + + @_log_if_debug_on + def draw_image(self, gc, x, y, im, transform=None): + # docstring inherited + + h, w = im.shape[:2] + imagecmd = "false 3 colorimage" + data = im[::-1, :, :3] # Vertically flipped rgb values. + hexdata = data.tobytes().hex("\n", -64) # Linewrap to 128 chars. + + if transform is None: + matrix = "1 0 0 1 0 0" + xscale = w / self.image_magnification + yscale = h / self.image_magnification + else: + matrix = " ".join(map(str, transform.frozen().to_values())) + xscale = 1.0 + yscale = 1.0 + + self._pswriter.write(f"""\ +gsave +{self._get_clip_cmd(gc)} +{x:g} {y:g} translate +[{matrix}] concat +{xscale:g} {yscale:g} scale +/DataString {w:d} string def +{w:d} {h:d} 8 [ {w:d} 0 0 -{h:d} 0 {h:d} ] +{{ +currentfile DataString readhexstring pop +}} bind {imagecmd} +{hexdata} +grestore +""") + + @_log_if_debug_on + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + clip = rgbFace is None and gc.get_hatch_path() is None + simplify = path.should_simplify and clip + ps = self._convert_path(path, transform, clip=clip, simplify=simplify) + self._draw_ps(ps, gc, rgbFace) + + @_log_if_debug_on + def draw_markers( + self, gc, marker_path, marker_trans, path, trans, rgbFace=None): + # docstring inherited + + ps_color = ( + None + if self._is_transparent(rgbFace) + else f'{_nums_to_str(rgbFace[0])} setgray' + if rgbFace[0] == rgbFace[1] == rgbFace[2] + else f'{_nums_to_str(*rgbFace[:3])} setrgbcolor') + + # construct the generic marker command: + + # don't want the translate to be global + ps_cmd = ['/o {', 'gsave', 'newpath', 'translate'] + + lw = gc.get_linewidth() + alpha = (gc.get_alpha() + if gc.get_forced_alpha() or len(gc.get_rgb()) == 3 + else gc.get_rgb()[3]) + stroke = lw > 0 and alpha > 0 + if stroke: + ps_cmd.append('%.1f setlinewidth' % lw) + ps_cmd.append(self._linejoin_cmd(gc.get_joinstyle())) + ps_cmd.append(self._linecap_cmd(gc.get_capstyle())) + + ps_cmd.append(self._convert_path(marker_path, marker_trans, + simplify=False)) + + if rgbFace: + if stroke: + ps_cmd.append('gsave') + if ps_color: + ps_cmd.extend([ps_color, 'fill']) + if stroke: + ps_cmd.append('grestore') + + if stroke: + ps_cmd.append('stroke') + ps_cmd.extend(['grestore', '} bind def']) + + for vertices, code in path.iter_segments( + trans, + clip=(0, 0, self.width*72, self.height*72), + simplify=False): + if len(vertices): + x, y = vertices[-2:] + ps_cmd.append(f"{x:g} {y:g} o") + + ps = '\n'.join(ps_cmd) + self._draw_ps(ps, gc, rgbFace, fill=False, stroke=False) + + @_log_if_debug_on + def draw_path_collection(self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position): + # Is the optimization worth it? Rough calculation: + # cost of emitting a path in-line is + # (len_path + 2) * uses_per_path + # cost of definition+use is + # (len_path + 3) + 3 * uses_per_path + len_path = len(paths[0].vertices) if len(paths) > 0 else 0 + uses_per_path = self._iter_collection_uses_per_path( + paths, all_transforms, offsets, facecolors, edgecolors) + should_do_optimization = \ + len_path + 3 * uses_per_path + 3 < (len_path + 2) * uses_per_path + if not should_do_optimization: + return RendererBase.draw_path_collection( + self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position) + + path_codes = [] + for i, (path, transform) in enumerate(self._iter_collection_raw_paths( + master_transform, paths, all_transforms)): + name = 'p%d_%d' % (self._path_collection_id, i) + path_bytes = self._convert_path(path, transform, simplify=False) + self._pswriter.write(f"""\ +/{name} {{ +newpath +translate +{path_bytes} +}} bind def +""") + path_codes.append(name) + + for xo, yo, path_id, gc0, rgbFace in self._iter_collection( + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, + antialiaseds, urls, offset_position): + ps = f"{xo:g} {yo:g} {path_id}" + self._draw_ps(ps, gc0, rgbFace) + + self._path_collection_id += 1 + + @_log_if_debug_on + def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): + # docstring inherited + if self._is_transparent(gc.get_rgb()): + return # Special handling for fully transparent. + + if not hasattr(self, "psfrag"): + self._logwarn_once( + "The PS backend determines usetex status solely based on " + "rcParams['text.usetex'] and does not support having " + "usetex=True only for some elements; this element will thus " + "be rendered as if usetex=False.") + self.draw_text(gc, x, y, s, prop, angle, False, mtext) + return + + w, h, bl = self.get_text_width_height_descent(s, prop, ismath="TeX") + fontsize = prop.get_size_in_points() + thetext = 'psmarker%d' % self.textcnt + color = _nums_to_str(*gc.get_rgb()[:3], sep=',') + fontcmd = {'sans-serif': r'{\sffamily %s}', + 'monospace': r'{\ttfamily %s}'}.get( + mpl.rcParams['font.family'][0], r'{\rmfamily %s}') + s = fontcmd % s + tex = r'\color[rgb]{%s} %s' % (color, s) + + # Stick to bottom-left alignment, so subtract descent from the text-normal + # direction since text is normally positioned by its baseline. + rangle = np.radians(angle + 90) + pos = _nums_to_str(x - bl * np.cos(rangle), y - bl * np.sin(rangle)) + self.psfrag.append( + r'\psfrag{%s}[bl][bl][1][%f]{\fontsize{%f}{%f}%s}' % ( + thetext, angle, fontsize, fontsize*1.25, tex)) + + self._pswriter.write(f"""\ +gsave +{pos} moveto +({thetext}) +show +grestore +""") + self.textcnt += 1 + + @_log_if_debug_on + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + + if self._is_transparent(gc.get_rgb()): + return # Special handling for fully transparent. + + if ismath == 'TeX': + return self.draw_tex(gc, x, y, s, prop, angle) + + if ismath: + return self.draw_mathtext(gc, x, y, s, prop, angle) + + stream = [] # list of (ps_name, x, char_name) + + if mpl.rcParams['ps.useafm']: + font = self._get_font_afm(prop) + ps_name = (font.postscript_name.encode("ascii", "replace") + .decode("ascii")) + scale = 0.001 * prop.get_size_in_points() + thisx = 0 + last_name = None # kerns returns 0 for None. + for c in s: + name = uni2type1.get(ord(c), f"uni{ord(c):04X}") + try: + width = font.get_width_from_char_name(name) + except KeyError: + name = 'question' + width = font.get_width_char('?') + kern = font.get_kern_dist_from_name(last_name, name) + last_name = name + thisx += kern * scale + stream.append((ps_name, thisx, name)) + thisx += width * scale + + else: + font = self._get_font_ttf(prop) + self._character_tracker.track(font, s) + for item in _text_helpers.layout(s, font): + ps_name = (item.ft_object.postscript_name + .encode("ascii", "replace").decode("ascii")) + glyph_name = item.ft_object.get_glyph_name(item.glyph_idx) + stream.append((ps_name, item.x, glyph_name)) + self.set_color(*gc.get_rgb()) + + for ps_name, group in itertools. \ + groupby(stream, lambda entry: entry[0]): + self.set_font(ps_name, prop.get_size_in_points(), False) + thetext = "\n".join(f"{x:g} 0 m /{name:s} glyphshow" + for _, x, name in group) + self._pswriter.write(f"""\ +gsave +{self._get_clip_cmd(gc)} +{x:g} {y:g} translate +{angle:g} rotate +{thetext} +grestore +""") + + @_log_if_debug_on + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw the math text using matplotlib.mathtext.""" + width, height, descent, glyphs, rects = \ + self._text2path.mathtext_parser.parse(s, 72, prop) + self.set_color(*gc.get_rgb()) + self._pswriter.write( + f"gsave\n" + f"{x:g} {y:g} translate\n" + f"{angle:g} rotate\n") + lastfont = None + for font, fontsize, num, ox, oy in glyphs: + self._character_tracker.track_glyph(font, num) + if (font.postscript_name, fontsize) != lastfont: + lastfont = font.postscript_name, fontsize + self._pswriter.write( + f"/{font.postscript_name} {fontsize} selectfont\n") + glyph_name = ( + font.get_name_char(chr(num)) if isinstance(font, AFM) else + font.get_glyph_name(font.get_char_index(num))) + self._pswriter.write( + f"{ox:g} {oy:g} moveto\n" + f"/{glyph_name} glyphshow\n") + for ox, oy, w, h in rects: + self._pswriter.write(f"{ox} {oy} {w} {h} rectfill\n") + self._pswriter.write("grestore\n") + + @_log_if_debug_on + def draw_gouraud_triangles(self, gc, points, colors, trans): + assert len(points) == len(colors) + if len(points) == 0: + return + assert points.ndim == 3 + assert points.shape[1] == 3 + assert points.shape[2] == 2 + assert colors.ndim == 3 + assert colors.shape[1] == 3 + assert colors.shape[2] == 4 + + shape = points.shape + flat_points = points.reshape((shape[0] * shape[1], 2)) + flat_points = trans.transform(flat_points) + flat_colors = colors.reshape((shape[0] * shape[1], 4)) + points_min = np.min(flat_points, axis=0) - (1 << 12) + points_max = np.max(flat_points, axis=0) + (1 << 12) + factor = np.ceil((2 ** 32 - 1) / (points_max - points_min)) + + xmin, ymin = points_min + xmax, ymax = points_max + + data = np.empty( + shape[0] * shape[1], + dtype=[('flags', 'u1'), ('points', '2>u4'), ('colors', '3u1')]) + data['flags'] = 0 + data['points'] = (flat_points - points_min) * factor + data['colors'] = flat_colors[:, :3] * 255.0 + hexdata = data.tobytes().hex("\n", -64) # Linewrap to 128 chars. + + self._pswriter.write(f"""\ +gsave +<< /ShadingType 4 + /ColorSpace [/DeviceRGB] + /BitsPerCoordinate 32 + /BitsPerComponent 8 + /BitsPerFlag 8 + /AntiAlias true + /Decode [ {xmin:g} {xmax:g} {ymin:g} {ymax:g} 0 1 0 1 0 1 ] + /DataSource < +{hexdata} +> +>> +shfill +grestore +""") + + def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): + """ + Emit the PostScript snippet *ps* with all the attributes from *gc* + applied. *ps* must consist of PostScript commands to construct a path. + + The *fill* and/or *stroke* kwargs can be set to False if the *ps* + string already includes filling and/or stroking, in which case + `_draw_ps` is just supplying properties and clipping. + """ + write = self._pswriter.write + mightstroke = (gc.get_linewidth() > 0 + and not self._is_transparent(gc.get_rgb())) + if not mightstroke: + stroke = False + if self._is_transparent(rgbFace): + fill = False + hatch = gc.get_hatch() + + if mightstroke: + self.set_linewidth(gc.get_linewidth()) + self.set_linejoin(gc.get_joinstyle()) + self.set_linecap(gc.get_capstyle()) + self.set_linedash(*gc.get_dashes()) + if mightstroke or hatch: + self.set_color(*gc.get_rgb()[:3]) + write('gsave\n') + + write(self._get_clip_cmd(gc)) + + write(ps.strip()) + write("\n") + + if fill: + if stroke or hatch: + write("gsave\n") + self.set_color(*rgbFace[:3], store=False) + write("fill\n") + if stroke or hatch: + write("grestore\n") + + if hatch: + hatch_name = self.create_hatch(hatch, gc.get_hatch_linewidth()) + write("gsave\n") + write(_nums_to_str(*gc.get_hatch_color()[:3])) + write(f" {hatch_name} setpattern fill grestore\n") + + if stroke: + write("stroke\n") + + write("grestore\n") + + +class _Orientation(Enum): + portrait, landscape = range(2) + + def swap_if_landscape(self, shape): + return shape[::-1] if self.name == "landscape" else shape + + +class FigureCanvasPS(FigureCanvasBase): + fixed_dpi = 72 + filetypes = {'ps': 'Postscript', + 'eps': 'Encapsulated Postscript'} + + def get_default_filetype(self): + return 'ps' + + def _print_ps( + self, fmt, outfile, *, + metadata=None, papertype=None, orientation='portrait', + bbox_inches_restore=None, **kwargs): + + dpi = self.figure.dpi + self.figure.dpi = 72 # Override the dpi kwarg + + dsc_comments = {} + if isinstance(outfile, (str, os.PathLike)): + filename = pathlib.Path(outfile).name + dsc_comments["Title"] = \ + filename.encode("ascii", "replace").decode("ascii") + dsc_comments["Creator"] = (metadata or {}).get( + "Creator", + f"Matplotlib v{mpl.__version__}, https://matplotlib.org/") + # See https://reproducible-builds.org/specs/source-date-epoch/ + source_date_epoch = os.getenv("SOURCE_DATE_EPOCH") + dsc_comments["CreationDate"] = ( + datetime.datetime.fromtimestamp( + int(source_date_epoch), + datetime.timezone.utc).strftime("%a %b %d %H:%M:%S %Y") + if source_date_epoch + else time.ctime()) + dsc_comments = "\n".join( + f"%%{k}: {v}" for k, v in dsc_comments.items()) + + if papertype is None: + papertype = mpl.rcParams['ps.papersize'] + papertype = papertype.lower() + _api.check_in_list(['figure', *papersize], papertype=papertype) + + orientation = _api.check_getitem( + _Orientation, orientation=orientation.lower()) + + printer = (self._print_figure_tex + if mpl.rcParams['text.usetex'] else + self._print_figure) + printer(fmt, outfile, dpi=dpi, dsc_comments=dsc_comments, + orientation=orientation, papertype=papertype, + bbox_inches_restore=bbox_inches_restore, **kwargs) + + def _print_figure( + self, fmt, outfile, *, + dpi, dsc_comments, orientation, papertype, + bbox_inches_restore=None): + """ + Render the figure to a filesystem path or a file-like object. + + Parameters are as for `.print_figure`, except that *dsc_comments* is a + string containing Document Structuring Convention comments, + generated from the *metadata* parameter to `.print_figure`. + """ + is_eps = fmt == 'eps' + if not (isinstance(outfile, (str, os.PathLike)) + or is_writable_file_like(outfile)): + raise ValueError("outfile must be a path or a file-like object") + + # find the appropriate papertype + width, height = self.figure.get_size_inches() + if is_eps or papertype == 'figure': + paper_width, paper_height = width, height + else: + paper_width, paper_height = orientation.swap_if_landscape( + papersize[papertype]) + + # center the figure on the paper + xo = 72 * 0.5 * (paper_width - width) + yo = 72 * 0.5 * (paper_height - height) + + llx = xo + lly = yo + urx = llx + self.figure.bbox.width + ury = lly + self.figure.bbox.height + rotation = 0 + if orientation is _Orientation.landscape: + llx, lly, urx, ury = lly, llx, ury, urx + xo, yo = 72 * paper_height - yo, xo + rotation = 90 + bbox = (llx, lly, urx, ury) + + self._pswriter = StringIO() + + # mixed mode rendering + ps_renderer = RendererPS(width, height, self._pswriter, imagedpi=dpi) + renderer = MixedModeRenderer( + self.figure, width, height, dpi, ps_renderer, + bbox_inches_restore=bbox_inches_restore) + + self.figure.draw(renderer) + + def print_figure_impl(fh): + # write the PostScript headers + if is_eps: + print("%!PS-Adobe-3.0 EPSF-3.0", file=fh) + else: + print("%!PS-Adobe-3.0", file=fh) + if papertype != 'figure': + print(f"%%DocumentPaperSizes: {papertype}", file=fh) + print("%%Pages: 1", file=fh) + print(f"%%LanguageLevel: 3\n" + f"{dsc_comments}\n" + f"%%Orientation: {orientation.name}\n" + f"{_get_bbox_header(bbox)}\n" + f"%%EndComments\n", + end="", file=fh) + + Ndict = len(_psDefs) + print("%%BeginProlog", file=fh) + if not mpl.rcParams['ps.useafm']: + Ndict += len(ps_renderer._character_tracker.used) + print("/mpldict %d dict def" % Ndict, file=fh) + print("mpldict begin", file=fh) + print("\n".join(_psDefs), file=fh) + if not mpl.rcParams['ps.useafm']: + for font_path, chars \ + in ps_renderer._character_tracker.used.items(): + if not chars: + continue + fonttype = mpl.rcParams['ps.fonttype'] + # Can't use more than 255 chars from a single Type 3 font. + if len(chars) > 255: + fonttype = 42 + fh.flush() + if fonttype == 3: + fh.write(_font_to_ps_type3(font_path, chars)) + else: # Type 42 only. + _font_to_ps_type42(font_path, chars, fh) + print("end", file=fh) + print("%%EndProlog", file=fh) + + if not is_eps: + print("%%Page: 1 1", file=fh) + print("mpldict begin", file=fh) + + print("%s translate" % _nums_to_str(xo, yo), file=fh) + if rotation: + print("%d rotate" % rotation, file=fh) + print(f"0 0 {_nums_to_str(width*72, height*72)} rectclip", file=fh) + + # write the figure + print(self._pswriter.getvalue(), file=fh) + + # write the trailer + print("end", file=fh) + print("showpage", file=fh) + if not is_eps: + print("%%EOF", file=fh) + fh.flush() + + if mpl.rcParams['ps.usedistiller']: + # We are going to use an external program to process the output. + # Write to a temporary file. + with TemporaryDirectory() as tmpdir: + tmpfile = os.path.join(tmpdir, "tmp.ps") + with open(tmpfile, 'w', encoding='latin-1') as fh: + print_figure_impl(fh) + if mpl.rcParams['ps.usedistiller'] == 'ghostscript': + _try_distill(gs_distill, + tmpfile, is_eps, ptype=papertype, bbox=bbox) + elif mpl.rcParams['ps.usedistiller'] == 'xpdf': + _try_distill(xpdf_distill, + tmpfile, is_eps, ptype=papertype, bbox=bbox) + _move_path_to_path_or_stream(tmpfile, outfile) + + else: # Write directly to outfile. + with cbook.open_file_cm(outfile, "w", encoding="latin-1") as file: + if not file_requires_unicode(file): + file = codecs.getwriter("latin-1")(file) + print_figure_impl(file) + + def _print_figure_tex( + self, fmt, outfile, *, + dpi, dsc_comments, orientation, papertype, + bbox_inches_restore=None): + """ + If :rc:`text.usetex` is True, a temporary pair of tex/eps files + are created to allow tex to manage the text layout via the PSFrags + package. These files are processed to yield the final ps or eps file. + + The rest of the behavior is as for `._print_figure`. + """ + is_eps = fmt == 'eps' + + width, height = self.figure.get_size_inches() + xo = 0 + yo = 0 + + llx = xo + lly = yo + urx = llx + self.figure.bbox.width + ury = lly + self.figure.bbox.height + bbox = (llx, lly, urx, ury) + + self._pswriter = StringIO() + + # mixed mode rendering + ps_renderer = RendererPS(width, height, self._pswriter, imagedpi=dpi) + renderer = MixedModeRenderer(self.figure, + width, height, dpi, ps_renderer, + bbox_inches_restore=bbox_inches_restore) + + self.figure.draw(renderer) + + # write to a temp file, we'll move it to outfile when done + with TemporaryDirectory() as tmpdir: + tmppath = pathlib.Path(tmpdir, "tmp.ps") + tmppath.write_text( + f"""\ +%!PS-Adobe-3.0 EPSF-3.0 +%%LanguageLevel: 3 +{dsc_comments} +{_get_bbox_header(bbox)} +%%EndComments +%%BeginProlog +/mpldict {len(_psDefs)} dict def +mpldict begin +{"".join(_psDefs)} +end +%%EndProlog +mpldict begin +{_nums_to_str(xo, yo)} translate +0 0 {_nums_to_str(width*72, height*72)} rectclip +{self._pswriter.getvalue()} +end +showpage +""", + encoding="latin-1") + + if orientation is _Orientation.landscape: # now, ready to rotate + width, height = height, width + bbox = (lly, llx, ury, urx) + + # set the paper size to the figure size if is_eps. The + # resulting ps file has the given size with correct bounding + # box so that there is no need to call 'pstoeps' + if is_eps or papertype == 'figure': + paper_width, paper_height = orientation.swap_if_landscape( + self.figure.get_size_inches()) + else: + paper_width, paper_height = papersize[papertype] + + psfrag_rotated = _convert_psfrags( + tmppath, ps_renderer.psfrag, paper_width, paper_height, + orientation.name) + + if (mpl.rcParams['ps.usedistiller'] == 'ghostscript' + or mpl.rcParams['text.usetex']): + _try_distill(gs_distill, + tmppath, is_eps, ptype=papertype, bbox=bbox, + rotated=psfrag_rotated) + elif mpl.rcParams['ps.usedistiller'] == 'xpdf': + _try_distill(xpdf_distill, + tmppath, is_eps, ptype=papertype, bbox=bbox, + rotated=psfrag_rotated) + + _move_path_to_path_or_stream(tmppath, outfile) + + print_ps = functools.partialmethod(_print_ps, "ps") + print_eps = functools.partialmethod(_print_ps, "eps") + + def draw(self): + self.figure.draw_without_rendering() + return super().draw() + + +def _convert_psfrags(tmppath, psfrags, paper_width, paper_height, orientation): + """ + When we want to use the LaTeX backend with postscript, we write PSFrag tags + to a temporary postscript file, each one marking a position for LaTeX to + render some text. convert_psfrags generates a LaTeX document containing the + commands to convert those tags to text. LaTeX/dvips produces the postscript + file that includes the actual text. + """ + with mpl.rc_context({ + "text.latex.preamble": + mpl.rcParams["text.latex.preamble"] + + mpl.texmanager._usepackage_if_not_loaded("color") + + mpl.texmanager._usepackage_if_not_loaded("graphicx") + + mpl.texmanager._usepackage_if_not_loaded("psfrag") + + r"\geometry{papersize={%(width)sin,%(height)sin},margin=0in}" + % {"width": paper_width, "height": paper_height} + }): + dvifile = TexManager().make_dvi( + "\n" + r"\begin{figure}""\n" + r" \centering\leavevmode""\n" + r" %(psfrags)s""\n" + r" \includegraphics*[angle=%(angle)s]{%(epsfile)s}""\n" + r"\end{figure}" + % { + "psfrags": "\n".join(psfrags), + "angle": 90 if orientation == 'landscape' else 0, + "epsfile": tmppath.resolve().as_posix(), + }, + fontsize=10) # tex's default fontsize. + + with TemporaryDirectory() as tmpdir: + psfile = os.path.join(tmpdir, "tmp.ps") + cbook._check_and_log_subprocess( + ['dvips', '-q', '-R0', '-o', psfile, dvifile], _log) + shutil.move(psfile, tmppath) + + # check if the dvips created a ps in landscape paper. Somehow, + # above latex+dvips results in a ps file in a landscape mode for a + # certain figure sizes (e.g., 8.3in, 5.8in which is a5). And the + # bounding box of the final output got messed up. We check see if + # the generated ps file is in landscape and return this + # information. The return value is used in pstoeps step to recover + # the correct bounding box. 2010-06-05 JJL + with open(tmppath) as fh: + psfrag_rotated = "Landscape" in fh.read(1000) + return psfrag_rotated + + +def _try_distill(func, tmppath, *args, **kwargs): + try: + func(str(tmppath), *args, **kwargs) + except mpl.ExecutableNotFoundError as exc: + _log.warning("%s. Distillation step skipped.", exc) + + +def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): + """ + Use ghostscript's pswrite or epswrite device to distill a file. + This yields smaller files without illegal encapsulated postscript + operators. The output is low-level, converting text to outlines. + """ + + if eps: + paper_option = ["-dEPSCrop"] + elif ptype == "figure": + # The bbox will have its lower-left corner at (0, 0), so upper-right + # corner corresponds with paper size. + paper_option = [f"-dDEVICEWIDTHPOINTS={bbox[2]}", + f"-dDEVICEHEIGHTPOINTS={bbox[3]}"] + else: + paper_option = [f"-sPAPERSIZE={ptype}"] + + psfile = tmpfile + '.ps' + dpi = mpl.rcParams['ps.distiller.res'] + + cbook._check_and_log_subprocess( + [mpl._get_executable_info("gs").executable, + "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write", + *paper_option, f"-sOutputFile={psfile}", tmpfile], + _log) + + os.remove(tmpfile) + shutil.move(psfile, tmpfile) + + # While it is best if above steps preserve the original bounding + # box, there seem to be cases when it is not. For those cases, + # the original bbox can be restored during the pstoeps step. + + if eps: + # For some versions of gs, above steps result in a ps file where the + # original bbox is no more correct. Do not adjust bbox for now. + pstoeps(tmpfile, bbox, rotated=rotated) + + +def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): + """ + Use ghostscript's ps2pdf and xpdf's/poppler's pdftops to distill a file. + This yields smaller files without illegal encapsulated postscript + operators. This distiller is preferred, generating high-level postscript + output that treats text as text. + """ + mpl._get_executable_info("gs") # Effectively checks for ps2pdf. + mpl._get_executable_info("pdftops") + + if eps: + paper_option = ["-dEPSCrop"] + elif ptype == "figure": + # The bbox will have its lower-left corner at (0, 0), so upper-right + # corner corresponds with paper size. + paper_option = [f"-dDEVICEWIDTHPOINTS#{bbox[2]}", + f"-dDEVICEHEIGHTPOINTS#{bbox[3]}"] + else: + paper_option = [f"-sPAPERSIZE#{ptype}"] + + with TemporaryDirectory() as tmpdir: + tmppdf = pathlib.Path(tmpdir, "tmp.pdf") + tmpps = pathlib.Path(tmpdir, "tmp.ps") + # Pass options as `-foo#bar` instead of `-foo=bar` to keep Windows + # happy (https://ghostscript.com/doc/9.56.1/Use.htm#MS_Windows). + cbook._check_and_log_subprocess( + ["ps2pdf", + "-dAutoFilterColorImages#false", + "-dAutoFilterGrayImages#false", + "-sAutoRotatePages#None", + "-sGrayImageFilter#FlateEncode", + "-sColorImageFilter#FlateEncode", + *paper_option, + tmpfile, tmppdf], _log) + cbook._check_and_log_subprocess( + ["pdftops", "-paper", "match", "-level3", tmppdf, tmpps], _log) + shutil.move(tmpps, tmpfile) + if eps: + pstoeps(tmpfile) + + +@_api.deprecated("3.9") +def get_bbox_header(lbrt, rotated=False): + """ + Return a postscript header string for the given bbox lbrt=(l, b, r, t). + Optionally, return rotate command. + """ + return _get_bbox_header(lbrt), (_get_rotate_command(lbrt) if rotated else "") + + +def _get_bbox_header(lbrt): + """Return a PostScript header string for bounding box *lbrt*=(l, b, r, t).""" + l, b, r, t = lbrt + return (f"%%BoundingBox: {int(l)} {int(b)} {math.ceil(r)} {math.ceil(t)}\n" + f"%%HiResBoundingBox: {l:.6f} {b:.6f} {r:.6f} {t:.6f}") + + +def _get_rotate_command(lbrt): + """Return a PostScript 90° rotation command for bounding box *lbrt*=(l, b, r, t).""" + l, b, r, t = lbrt + return f"{l+r:.2f} {0:.2f} translate\n90 rotate" + + +def pstoeps(tmpfile, bbox=None, rotated=False): + """ + Convert the postscript to encapsulated postscript. The bbox of + the eps file will be replaced with the given *bbox* argument. If + None, original bbox will be used. + """ + + epsfile = tmpfile + '.eps' + with open(epsfile, 'wb') as epsh, open(tmpfile, 'rb') as tmph: + write = epsh.write + # Modify the header: + for line in tmph: + if line.startswith(b'%!PS'): + write(b"%!PS-Adobe-3.0 EPSF-3.0\n") + if bbox: + write(_get_bbox_header(bbox).encode('ascii') + b'\n') + elif line.startswith(b'%%EndComments'): + write(line) + write(b'%%BeginProlog\n' + b'save\n' + b'countdictstack\n' + b'mark\n' + b'newpath\n' + b'/showpage {} def\n' + b'/setpagedevice {pop} def\n' + b'%%EndProlog\n' + b'%%Page 1 1\n') + if rotated: # The output eps file need to be rotated. + write(_get_rotate_command(bbox).encode('ascii') + b'\n') + break + elif bbox and line.startswith((b'%%Bound', b'%%HiResBound', + b'%%DocumentMedia', b'%%Pages')): + pass + else: + write(line) + # Now rewrite the rest of the file, and modify the trailer. + # This is done in a second loop such that the header of the embedded + # eps file is not modified. + for line in tmph: + if line.startswith(b'%%EOF'): + write(b'cleartomark\n' + b'countdictstack\n' + b'exch sub { end } repeat\n' + b'restore\n' + b'showpage\n' + b'%%EOF\n') + elif line.startswith(b'%%PageBoundingBox'): + pass + else: + write(line) + + os.remove(tmpfile) + shutil.move(epsfile, tmpfile) + + +FigureManagerPS = FigureManagerBase + + +# The following Python dictionary psDefs contains the entries for the +# PostScript dictionary mpldict. This dictionary implements most of +# the matplotlib primitives and some abbreviations. +# +# References: +# https://www.adobe.com/content/dam/acom/en/devnet/actionscript/articles/PLRM.pdf +# http://preserve.mactech.com/articles/mactech/Vol.09/09.04/PostscriptTutorial +# http://www.math.ubc.ca/people/faculty/cass/graphics/text/www/ +# + +# The usage comments use the notation of the operator summary +# in the PostScript Language reference manual. +_psDefs = [ + # name proc *_d* - + # Note that this cannot be bound to /d, because when embedding a Type3 font + # we may want to define a "d" glyph using "/d{...} d" which would locally + # overwrite the definition. + "/_d { bind def } bind def", + # x y *m* - + "/m { moveto } _d", + # x y *l* - + "/l { lineto } _d", + # x y *r* - + "/r { rlineto } _d", + # x1 y1 x2 y2 x y *c* - + "/c { curveto } _d", + # *cl* - + "/cl { closepath } _d", + # *ce* - + "/ce { closepath eofill } _d", + # wx wy llx lly urx ury *setcachedevice* - + "/sc { setcachedevice } _d", +] + + +@_Backend.export +class _BackendPS(_Backend): + backend_version = 'Level II' + FigureCanvas = FigureCanvasPS diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_qt5agg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_qt5agg.py new file mode 100644 index 0000000000000000000000000000000000000000..8a92fd5135d5a3f4b05b3a71d1089e15d031fd59 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_qt5agg.py @@ -0,0 +1,14 @@ +""" +Render to qt from agg +""" +from .. import backends + +backends._QT_FORCE_QT5_BINDING = True +from .backend_qtagg import ( # noqa: F401, E402 # pylint: disable=W0611 + _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT, + FigureCanvasAgg, FigureCanvasQT) + + +@_BackendQTAgg.export +class _BackendQT5Agg(_BackendQTAgg): + pass diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_qtagg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_qtagg.py new file mode 100644 index 0000000000000000000000000000000000000000..256e50a3d1c3305be1b5fd5e9da9cb9807f4ec1f --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_qtagg.py @@ -0,0 +1,86 @@ +""" +Render to qt from agg. +""" + +import ctypes + +from matplotlib.transforms import Bbox + +from .qt_compat import QT_API, QtCore, QtGui +from .backend_agg import FigureCanvasAgg +from .backend_qt import _BackendQT, FigureCanvasQT +from .backend_qt import ( # noqa: F401 # pylint: disable=W0611 + FigureManagerQT, NavigationToolbar2QT) + + +class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): + + def paintEvent(self, event): + """ + Copy the image from the Agg canvas to the qt.drawable. + + In Qt, all drawing should be done inside of here when a widget is + shown onscreen. + """ + self._draw_idle() # Only does something if a draw is pending. + + # If the canvas does not have a renderer, then give up and wait for + # FigureCanvasAgg.draw(self) to be called. + if not hasattr(self, 'renderer'): + return + + painter = QtGui.QPainter(self) + try: + # See documentation of QRect: bottom() and right() are off + # by 1, so use left() + width() and top() + height(). + rect = event.rect() + # scale rect dimensions using the screen dpi ratio to get + # correct values for the Figure coordinates (rather than + # QT5's coords) + width = rect.width() * self.device_pixel_ratio + height = rect.height() * self.device_pixel_ratio + left, top = self.mouseEventCoords(rect.topLeft()) + # shift the "top" by the height of the image to get the + # correct corner for our coordinate system + bottom = top - height + # same with the right side of the image + right = left + width + # create a buffer using the image bounding box + bbox = Bbox([[left, bottom], [right, top]]) + buf = memoryview(self.copy_from_bbox(bbox)) + + if QT_API == "PyQt6": + from PyQt6 import sip + ptr = int(sip.voidptr(buf)) + else: + ptr = buf + + painter.eraseRect(rect) # clear the widget canvas + qimage = QtGui.QImage(ptr, buf.shape[1], buf.shape[0], + QtGui.QImage.Format.Format_RGBA8888) + qimage.setDevicePixelRatio(self.device_pixel_ratio) + # set origin using original QT coordinates + origin = QtCore.QPoint(rect.left(), rect.top()) + painter.drawImage(origin, qimage) + # Adjust the buf reference count to work around a memory + # leak bug in QImage under PySide. + if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12): + ctypes.c_long.from_address(id(buf)).value = 1 + + self._draw_rect_callback(painter) + finally: + painter.end() + + def print_figure(self, *args, **kwargs): + super().print_figure(*args, **kwargs) + # In some cases, Qt will itself trigger a paint event after closing the file + # save dialog. When that happens, we need to be sure that the internal canvas is + # re-drawn. However, if the user is using an automatically-chosen Qt backend but + # saving with a different backend (such as pgf), we do not want to trigger a + # full draw in Qt, so just set the flag for next time. + self._draw_pending = True + + +@_BackendQT.export +class _BackendQTAgg(_BackendQT): + FigureCanvas = FigureCanvasQTAgg diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_svg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_svg.py new file mode 100644 index 0000000000000000000000000000000000000000..2193dc6b6cdcfd29f6b1e1c9d441afcb638c0cef --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_svg.py @@ -0,0 +1,1380 @@ +import base64 +import codecs +import datetime +import gzip +import hashlib +from io import BytesIO +import itertools +import logging +import os +import re +import uuid + +import numpy as np +from PIL import Image + +import matplotlib as mpl +from matplotlib import cbook, font_manager as fm +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) +from matplotlib.backends.backend_mixed import MixedModeRenderer +from matplotlib.colors import rgb2hex +from matplotlib.dates import UTC +from matplotlib.path import Path +from matplotlib import _path +from matplotlib.transforms import Affine2D, Affine2DBase + + +_log = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- +# SimpleXMLWriter class +# +# Based on an original by Fredrik Lundh, but modified here to: +# 1. Support modern Python idioms +# 2. Remove encoding support (it's handled by the file writer instead) +# 3. Support proper indentation +# 4. Minify things a little bit + +# -------------------------------------------------------------------- +# The SimpleXMLWriter module is +# +# Copyright (c) 2001-2004 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + + +def _escape_cdata(s): + s = s.replace("&", "&") + s = s.replace("<", "<") + s = s.replace(">", ">") + return s + + +_escape_xml_comment = re.compile(r'-(?=-)') + + +def _escape_comment(s): + s = _escape_cdata(s) + return _escape_xml_comment.sub('- ', s) + + +def _escape_attrib(s): + s = s.replace("&", "&") + s = s.replace("'", "'") + s = s.replace('"', """) + s = s.replace("<", "<") + s = s.replace(">", ">") + return s + + +def _quote_escape_attrib(s): + return ('"' + _escape_cdata(s) + '"' if '"' not in s else + "'" + _escape_cdata(s) + "'" if "'" not in s else + '"' + _escape_attrib(s) + '"') + + +def _short_float_fmt(x): + """ + Create a short string representation of a float, which is %f + formatting with trailing zeros and the decimal point removed. + """ + return f'{x:f}'.rstrip('0').rstrip('.') + + +class XMLWriter: + """ + Parameters + ---------- + file : writable text file-like object + """ + + def __init__(self, file): + self.__write = file.write + if hasattr(file, "flush"): + self.flush = file.flush + self.__open = 0 # true if start tag is open + self.__tags = [] + self.__data = [] + self.__indentation = " " * 64 + + def __flush(self, indent=True): + # flush internal buffers + if self.__open: + if indent: + self.__write(">\n") + else: + self.__write(">") + self.__open = 0 + if self.__data: + data = ''.join(self.__data) + self.__write(_escape_cdata(data)) + self.__data = [] + + def start(self, tag, attrib={}, **extra): + """ + Open a new element. Attributes can be given as keyword + arguments, or as a string/string dictionary. The method returns + an opaque identifier that can be passed to the :meth:`close` + method, to close all open elements up to and including this one. + + Parameters + ---------- + tag + Element tag. + attrib + Attribute dictionary. Alternatively, attributes can be given as + keyword arguments. + + Returns + ------- + An element identifier. + """ + self.__flush() + tag = _escape_cdata(tag) + self.__data = [] + self.__tags.append(tag) + self.__write(self.__indentation[:len(self.__tags) - 1]) + self.__write(f"<{tag}") + for k, v in {**attrib, **extra}.items(): + if v: + k = _escape_cdata(k) + v = _quote_escape_attrib(v) + self.__write(f' {k}={v}') + self.__open = 1 + return len(self.__tags) - 1 + + def comment(self, comment): + """ + Add a comment to the output stream. + + Parameters + ---------- + comment : str + Comment text. + """ + self.__flush() + self.__write(self.__indentation[:len(self.__tags)]) + self.__write(f"\n") + + def data(self, text): + """ + Add character data to the output stream. + + Parameters + ---------- + text : str + Character data. + """ + self.__data.append(text) + + def end(self, tag=None, indent=True): + """ + Close the current element (opened by the most recent call to + :meth:`start`). + + Parameters + ---------- + tag + Element tag. If given, the tag must match the start tag. If + omitted, the current element is closed. + indent : bool, default: True + """ + if tag: + assert self.__tags, f"unbalanced end({tag})" + assert _escape_cdata(tag) == self.__tags[-1], \ + f"expected end({self.__tags[-1]}), got {tag}" + else: + assert self.__tags, "unbalanced end()" + tag = self.__tags.pop() + if self.__data: + self.__flush(indent) + elif self.__open: + self.__open = 0 + self.__write("/>\n") + return + if indent: + self.__write(self.__indentation[:len(self.__tags)]) + self.__write(f"\n") + + def close(self, id): + """ + Close open elements, up to (and including) the element identified + by the given identifier. + + Parameters + ---------- + id + Element identifier, as returned by the :meth:`start` method. + """ + while len(self.__tags) > id: + self.end() + + def element(self, tag, text=None, attrib={}, **extra): + """ + Add an entire element. This is the same as calling :meth:`start`, + :meth:`data`, and :meth:`end` in sequence. The *text* argument can be + omitted. + """ + self.start(tag, attrib, **extra) + if text: + self.data(text) + self.end(indent=False) + + def flush(self): + """Flush the output stream.""" + pass # replaced by the constructor + + +def _generate_transform(transform_list): + parts = [] + for type, value in transform_list: + if (type == 'scale' and (value == (1,) or value == (1, 1)) + or type == 'translate' and value == (0, 0) + or type == 'rotate' and value == (0,)): + continue + if type == 'matrix' and isinstance(value, Affine2DBase): + value = value.to_values() + parts.append('{}({})'.format( + type, ' '.join(_short_float_fmt(x) for x in value))) + return ' '.join(parts) + + +def _generate_css(attrib): + return "; ".join(f"{k}: {v}" for k, v in attrib.items()) + + +_capstyle_d = {'projecting': 'square', 'butt': 'butt', 'round': 'round'} + + +def _check_is_str(info, key): + if not isinstance(info, str): + raise TypeError(f'Invalid type for {key} metadata. Expected str, not ' + f'{type(info)}.') + + +def _check_is_iterable_of_str(infos, key): + if np.iterable(infos): + for info in infos: + if not isinstance(info, str): + raise TypeError(f'Invalid type for {key} metadata. Expected ' + f'iterable of str, not {type(info)}.') + else: + raise TypeError(f'Invalid type for {key} metadata. Expected str or ' + f'iterable of str, not {type(infos)}.') + + +class RendererSVG(RendererBase): + def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, + *, metadata=None): + self.width = width + self.height = height + self.writer = XMLWriter(svgwriter) + self.image_dpi = image_dpi # actual dpi at which we rasterize stuff + + if basename is None: + basename = getattr(svgwriter, "name", "") + if not isinstance(basename, str): + basename = "" + self.basename = basename + + self._groupd = {} + self._image_counter = itertools.count() + self._clip_path_ids = {} + self._clipd = {} + self._markers = {} + self._path_collection_id = 0 + self._hatchd = {} + self._has_gouraud = False + self._n_gradients = 0 + + super().__init__() + self._glyph_map = dict() + str_height = _short_float_fmt(height) + str_width = _short_float_fmt(width) + svgwriter.write(svgProlog) + self._start_id = self.writer.start( + 'svg', + width=f'{str_width}pt', + height=f'{str_height}pt', + viewBox=f'0 0 {str_width} {str_height}', + xmlns="http://www.w3.org/2000/svg", + version="1.1", + id=mpl.rcParams['svg.id'], + attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"}) + self._write_metadata(metadata) + self._write_default_style() + + def _get_clippath_id(self, clippath): + """ + Returns a stable and unique identifier for the *clippath* argument + object within the current rendering context. + + This allows plots that include custom clip paths to produce identical + SVG output on each render, provided that the :rc:`svg.hashsalt` config + setting and the ``SOURCE_DATE_EPOCH`` build-time environment variable + are set to fixed values. + """ + if clippath not in self._clip_path_ids: + self._clip_path_ids[clippath] = len(self._clip_path_ids) + return self._clip_path_ids[clippath] + + def finalize(self): + self._write_clips() + self._write_hatches() + self.writer.close(self._start_id) + self.writer.flush() + + def _write_metadata(self, metadata): + # Add metadata following the Dublin Core Metadata Initiative, and the + # Creative Commons Rights Expression Language. This is mainly for + # compatibility with Inkscape. + if metadata is None: + metadata = {} + metadata = { + 'Format': 'image/svg+xml', + 'Type': 'http://purl.org/dc/dcmitype/StillImage', + 'Creator': + f'Matplotlib v{mpl.__version__}, https://matplotlib.org/', + **metadata + } + writer = self.writer + + if 'Title' in metadata: + title = metadata['Title'] + _check_is_str(title, 'Title') + writer.element('title', text=title) + + # Special handling. + date = metadata.get('Date', None) + if date is not None: + if isinstance(date, str): + dates = [date] + elif isinstance(date, (datetime.datetime, datetime.date)): + dates = [date.isoformat()] + elif np.iterable(date): + dates = [] + for d in date: + if isinstance(d, str): + dates.append(d) + elif isinstance(d, (datetime.datetime, datetime.date)): + dates.append(d.isoformat()) + else: + raise TypeError( + f'Invalid type for Date metadata. ' + f'Expected iterable of str, date, or datetime, ' + f'not {type(d)}.') + else: + raise TypeError(f'Invalid type for Date metadata. ' + f'Expected str, date, datetime, or iterable ' + f'of the same, not {type(date)}.') + metadata['Date'] = '/'.join(dates) + elif 'Date' not in metadata: + # Do not add `Date` if the user explicitly set `Date` to `None` + # Get source date from SOURCE_DATE_EPOCH, if set. + # See https://reproducible-builds.org/specs/source-date-epoch/ + date = os.getenv("SOURCE_DATE_EPOCH") + if date: + date = datetime.datetime.fromtimestamp(int(date), datetime.timezone.utc) + metadata['Date'] = date.replace(tzinfo=UTC).isoformat() + else: + metadata['Date'] = datetime.datetime.today().isoformat() + + mid = None + def ensure_metadata(mid): + if mid is not None: + return mid + mid = writer.start('metadata') + writer.start('rdf:RDF', attrib={ + 'xmlns:dc': "http://purl.org/dc/elements/1.1/", + 'xmlns:cc': "http://creativecommons.org/ns#", + 'xmlns:rdf': "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + }) + writer.start('cc:Work') + return mid + + uri = metadata.pop('Type', None) + if uri is not None: + mid = ensure_metadata(mid) + writer.element('dc:type', attrib={'rdf:resource': uri}) + + # Single value only. + for key in ['Title', 'Coverage', 'Date', 'Description', 'Format', + 'Identifier', 'Language', 'Relation', 'Source']: + info = metadata.pop(key, None) + if info is not None: + mid = ensure_metadata(mid) + _check_is_str(info, key) + writer.element(f'dc:{key.lower()}', text=info) + + # Multiple Agent values. + for key in ['Creator', 'Contributor', 'Publisher', 'Rights']: + agents = metadata.pop(key, None) + if agents is None: + continue + + if isinstance(agents, str): + agents = [agents] + + _check_is_iterable_of_str(agents, key) + # Now we know that we have an iterable of str + mid = ensure_metadata(mid) + writer.start(f'dc:{key.lower()}') + for agent in agents: + writer.start('cc:Agent') + writer.element('dc:title', text=agent) + writer.end('cc:Agent') + writer.end(f'dc:{key.lower()}') + + # Multiple values. + keywords = metadata.pop('Keywords', None) + if keywords is not None: + if isinstance(keywords, str): + keywords = [keywords] + _check_is_iterable_of_str(keywords, 'Keywords') + # Now we know that we have an iterable of str + mid = ensure_metadata(mid) + writer.start('dc:subject') + writer.start('rdf:Bag') + for keyword in keywords: + writer.element('rdf:li', text=keyword) + writer.end('rdf:Bag') + writer.end('dc:subject') + + if mid is not None: + writer.close(mid) + + if metadata: + raise ValueError('Unknown metadata key(s) passed to SVG writer: ' + + ','.join(metadata)) + + def _write_default_style(self): + writer = self.writer + default_style = _generate_css({ + 'stroke-linejoin': 'round', + 'stroke-linecap': 'butt'}) + writer.start('defs') + writer.element('style', type='text/css', text='*{%s}' % default_style) + writer.end('defs') + + def _make_id(self, type, content): + salt = mpl.rcParams['svg.hashsalt'] + if salt is None: + salt = str(uuid.uuid4()) + m = hashlib.sha256() + m.update(salt.encode('utf8')) + m.update(str(content).encode('utf8')) + return f'{type}{m.hexdigest()[:10]}' + + def _make_flip_transform(self, transform): + return transform + Affine2D().scale(1, -1).translate(0, self.height) + + def _get_hatch(self, gc, rgbFace): + """ + Create a new hatch pattern + """ + if rgbFace is not None: + rgbFace = tuple(rgbFace) + edge = gc.get_hatch_color() + if edge is not None: + edge = tuple(edge) + lw = gc.get_hatch_linewidth() + dictkey = (gc.get_hatch(), rgbFace, edge, lw) + oid = self._hatchd.get(dictkey) + if oid is None: + oid = self._make_id('h', dictkey) + self._hatchd[dictkey] = ((gc.get_hatch_path(), rgbFace, edge, lw), oid) + else: + _, oid = oid + return oid + + def _write_hatches(self): + if not len(self._hatchd): + return + HATCH_SIZE = 72 + writer = self.writer + writer.start('defs') + for (path, face, stroke, lw), oid in self._hatchd.values(): + writer.start( + 'pattern', + id=oid, + patternUnits="userSpaceOnUse", + x="0", y="0", width=str(HATCH_SIZE), + height=str(HATCH_SIZE)) + path_data = self._convert_path( + path, + Affine2D() + .scale(HATCH_SIZE).scale(1.0, -1.0).translate(0, HATCH_SIZE), + simplify=False) + if face is None: + fill = 'none' + else: + fill = rgb2hex(face) + writer.element( + 'rect', + x="0", y="0", width=str(HATCH_SIZE+1), + height=str(HATCH_SIZE+1), + fill=fill) + hatch_style = { + 'fill': rgb2hex(stroke), + 'stroke': rgb2hex(stroke), + 'stroke-width': str(lw), + 'stroke-linecap': 'butt', + 'stroke-linejoin': 'miter' + } + if stroke[3] < 1: + hatch_style['stroke-opacity'] = str(stroke[3]) + writer.element( + 'path', + d=path_data, + style=_generate_css(hatch_style) + ) + writer.end('pattern') + writer.end('defs') + + def _get_style_dict(self, gc, rgbFace): + """Generate a style string from the GraphicsContext and rgbFace.""" + attrib = {} + + forced_alpha = gc.get_forced_alpha() + + if gc.get_hatch() is not None: + attrib['fill'] = f"url(#{self._get_hatch(gc, rgbFace)})" + if (rgbFace is not None and len(rgbFace) == 4 and rgbFace[3] != 1.0 + and not forced_alpha): + attrib['fill-opacity'] = _short_float_fmt(rgbFace[3]) + else: + if rgbFace is None: + attrib['fill'] = 'none' + else: + if tuple(rgbFace[:3]) != (0, 0, 0): + attrib['fill'] = rgb2hex(rgbFace) + if (len(rgbFace) == 4 and rgbFace[3] != 1.0 + and not forced_alpha): + attrib['fill-opacity'] = _short_float_fmt(rgbFace[3]) + + if forced_alpha and gc.get_alpha() != 1.0: + attrib['opacity'] = _short_float_fmt(gc.get_alpha()) + + offset, seq = gc.get_dashes() + if seq is not None: + attrib['stroke-dasharray'] = ','.join( + _short_float_fmt(val) for val in seq) + attrib['stroke-dashoffset'] = _short_float_fmt(float(offset)) + + linewidth = gc.get_linewidth() + if linewidth: + rgb = gc.get_rgb() + attrib['stroke'] = rgb2hex(rgb) + if not forced_alpha and rgb[3] != 1.0: + attrib['stroke-opacity'] = _short_float_fmt(rgb[3]) + if linewidth != 1.0: + attrib['stroke-width'] = _short_float_fmt(linewidth) + if gc.get_joinstyle() != 'round': + attrib['stroke-linejoin'] = gc.get_joinstyle() + if gc.get_capstyle() != 'butt': + attrib['stroke-linecap'] = _capstyle_d[gc.get_capstyle()] + + return attrib + + def _get_style(self, gc, rgbFace): + return _generate_css(self._get_style_dict(gc, rgbFace)) + + def _get_clip_attrs(self, gc): + cliprect = gc.get_clip_rectangle() + clippath, clippath_trans = gc.get_clip_path() + if clippath is not None: + clippath_trans = self._make_flip_transform(clippath_trans) + dictkey = (self._get_clippath_id(clippath), str(clippath_trans)) + elif cliprect is not None: + x, y, w, h = cliprect.bounds + y = self.height-(y+h) + dictkey = (x, y, w, h) + else: + return {} + clip = self._clipd.get(dictkey) + if clip is None: + oid = self._make_id('p', dictkey) + if clippath is not None: + self._clipd[dictkey] = ((clippath, clippath_trans), oid) + else: + self._clipd[dictkey] = (dictkey, oid) + else: + _, oid = clip + return {'clip-path': f'url(#{oid})'} + + def _write_clips(self): + if not len(self._clipd): + return + writer = self.writer + writer.start('defs') + for clip, oid in self._clipd.values(): + writer.start('clipPath', id=oid) + if len(clip) == 2: + clippath, clippath_trans = clip + path_data = self._convert_path( + clippath, clippath_trans, simplify=False) + writer.element('path', d=path_data) + else: + x, y, w, h = clip + writer.element( + 'rect', + x=_short_float_fmt(x), + y=_short_float_fmt(y), + width=_short_float_fmt(w), + height=_short_float_fmt(h)) + writer.end('clipPath') + writer.end('defs') + + def open_group(self, s, gid=None): + # docstring inherited + if gid: + self.writer.start('g', id=gid) + else: + self._groupd[s] = self._groupd.get(s, 0) + 1 + self.writer.start('g', id=f"{s}_{self._groupd[s]:d}") + + def close_group(self, s): + # docstring inherited + self.writer.end('g') + + def option_image_nocomposite(self): + # docstring inherited + return not mpl.rcParams['image.composite_image'] + + def _convert_path(self, path, transform=None, clip=None, simplify=None, + sketch=None): + if clip: + clip = (0.0, 0.0, self.width, self.height) + else: + clip = None + return _path.convert_to_string( + path, transform, clip, simplify, sketch, 6, + [b'M', b'L', b'Q', b'C', b'z'], False).decode('ascii') + + def draw_path(self, gc, path, transform, rgbFace=None): + # docstring inherited + trans_and_flip = self._make_flip_transform(transform) + clip = (rgbFace is None and gc.get_hatch_path() is None) + simplify = path.should_simplify and clip + path_data = self._convert_path( + path, trans_and_flip, clip=clip, simplify=simplify, + sketch=gc.get_sketch_params()) + + if gc.get_url() is not None: + self.writer.start('a', {'xlink:href': gc.get_url()}) + self.writer.element('path', d=path_data, **self._get_clip_attrs(gc), + style=self._get_style(gc, rgbFace)) + if gc.get_url() is not None: + self.writer.end('a') + + def draw_markers( + self, gc, marker_path, marker_trans, path, trans, rgbFace=None): + # docstring inherited + + if not len(path.vertices): + return + + writer = self.writer + path_data = self._convert_path( + marker_path, + marker_trans + Affine2D().scale(1.0, -1.0), + simplify=False) + style = self._get_style_dict(gc, rgbFace) + dictkey = (path_data, _generate_css(style)) + oid = self._markers.get(dictkey) + style = _generate_css({k: v for k, v in style.items() + if k.startswith('stroke')}) + + if oid is None: + oid = self._make_id('m', dictkey) + writer.start('defs') + writer.element('path', id=oid, d=path_data, style=style) + writer.end('defs') + self._markers[dictkey] = oid + + writer.start('g', **self._get_clip_attrs(gc)) + if gc.get_url() is not None: + self.writer.start('a', {'xlink:href': gc.get_url()}) + trans_and_flip = self._make_flip_transform(trans) + attrib = {'xlink:href': f'#{oid}'} + clip = (0, 0, self.width*72, self.height*72) + for vertices, code in path.iter_segments( + trans_and_flip, clip=clip, simplify=False): + if len(vertices): + x, y = vertices[-2:] + attrib['x'] = _short_float_fmt(x) + attrib['y'] = _short_float_fmt(y) + attrib['style'] = self._get_style(gc, rgbFace) + writer.element('use', attrib=attrib) + if gc.get_url() is not None: + self.writer.end('a') + writer.end('g') + + def draw_path_collection(self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position): + # Is the optimization worth it? Rough calculation: + # cost of emitting a path in-line is + # (len_path + 5) * uses_per_path + # cost of definition+use is + # (len_path + 3) + 9 * uses_per_path + len_path = len(paths[0].vertices) if len(paths) > 0 else 0 + uses_per_path = self._iter_collection_uses_per_path( + paths, all_transforms, offsets, facecolors, edgecolors) + should_do_optimization = \ + len_path + 9 * uses_per_path + 3 < (len_path + 5) * uses_per_path + if not should_do_optimization: + return super().draw_path_collection( + gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position) + + writer = self.writer + path_codes = [] + writer.start('defs') + for i, (path, transform) in enumerate(self._iter_collection_raw_paths( + master_transform, paths, all_transforms)): + transform = Affine2D(transform.get_matrix()).scale(1.0, -1.0) + d = self._convert_path(path, transform, simplify=False) + oid = 'C{:x}_{:x}_{}'.format( + self._path_collection_id, i, self._make_id('', d)) + writer.element('path', id=oid, d=d) + path_codes.append(oid) + writer.end('defs') + + for xo, yo, path_id, gc0, rgbFace in self._iter_collection( + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, + antialiaseds, urls, offset_position): + url = gc0.get_url() + if url is not None: + writer.start('a', attrib={'xlink:href': url}) + clip_attrs = self._get_clip_attrs(gc0) + if clip_attrs: + writer.start('g', **clip_attrs) + attrib = { + 'xlink:href': f'#{path_id}', + 'x': _short_float_fmt(xo), + 'y': _short_float_fmt(self.height - yo), + 'style': self._get_style(gc0, rgbFace) + } + writer.element('use', attrib=attrib) + if clip_attrs: + writer.end('g') + if url is not None: + writer.end('a') + + self._path_collection_id += 1 + + def _draw_gouraud_triangle(self, transformed_points, colors): + # This uses a method described here: + # + # http://www.svgopen.org/2005/papers/Converting3DFaceToSVG/index.html + # + # that uses three overlapping linear gradients to simulate a + # Gouraud triangle. Each gradient goes from fully opaque in + # one corner to fully transparent along the opposite edge. + # The line between the stop points is perpendicular to the + # opposite edge. Underlying these three gradients is a solid + # triangle whose color is the average of all three points. + + avg_color = np.average(colors, axis=0) + if avg_color[-1] == 0: + # Skip fully-transparent triangles + return + + writer = self.writer + writer.start('defs') + for i in range(3): + x1, y1 = transformed_points[i] + x2, y2 = transformed_points[(i + 1) % 3] + x3, y3 = transformed_points[(i + 2) % 3] + rgba_color = colors[i] + + if x2 == x3: + xb = x2 + yb = y1 + elif y2 == y3: + xb = x1 + yb = y2 + else: + m1 = (y2 - y3) / (x2 - x3) + b1 = y2 - (m1 * x2) + m2 = -(1.0 / m1) + b2 = y1 - (m2 * x1) + xb = (-b1 + b2) / (m1 - m2) + yb = m2 * xb + b2 + + writer.start( + 'linearGradient', + id=f"GR{self._n_gradients:x}_{i:d}", + gradientUnits="userSpaceOnUse", + x1=_short_float_fmt(x1), y1=_short_float_fmt(y1), + x2=_short_float_fmt(xb), y2=_short_float_fmt(yb)) + writer.element( + 'stop', + offset='1', + style=_generate_css({ + 'stop-color': rgb2hex(avg_color), + 'stop-opacity': _short_float_fmt(rgba_color[-1])})) + writer.element( + 'stop', + offset='0', + style=_generate_css({'stop-color': rgb2hex(rgba_color), + 'stop-opacity': "0"})) + + writer.end('linearGradient') + + writer.end('defs') + + # triangle formation using "path" + dpath = (f"M {_short_float_fmt(x1)},{_short_float_fmt(y1)}" + f" L {_short_float_fmt(x2)},{_short_float_fmt(y2)}" + f" {_short_float_fmt(x3)},{_short_float_fmt(y3)} Z") + + writer.element( + 'path', + attrib={'d': dpath, + 'fill': rgb2hex(avg_color), + 'fill-opacity': '1', + 'shape-rendering': "crispEdges"}) + + writer.start( + 'g', + attrib={'stroke': "none", + 'stroke-width': "0", + 'shape-rendering': "crispEdges", + 'filter': "url(#colorMat)"}) + + writer.element( + 'path', + attrib={'d': dpath, + 'fill': f'url(#GR{self._n_gradients:x}_0)', + 'shape-rendering': "crispEdges"}) + + writer.element( + 'path', + attrib={'d': dpath, + 'fill': f'url(#GR{self._n_gradients:x}_1)', + 'filter': 'url(#colorAdd)', + 'shape-rendering': "crispEdges"}) + + writer.element( + 'path', + attrib={'d': dpath, + 'fill': f'url(#GR{self._n_gradients:x}_2)', + 'filter': 'url(#colorAdd)', + 'shape-rendering': "crispEdges"}) + + writer.end('g') + + self._n_gradients += 1 + + def draw_gouraud_triangles(self, gc, triangles_array, colors_array, + transform): + writer = self.writer + writer.start('g', **self._get_clip_attrs(gc)) + transform = transform.frozen() + trans_and_flip = self._make_flip_transform(transform) + + if not self._has_gouraud: + self._has_gouraud = True + writer.start( + 'filter', + id='colorAdd') + writer.element( + 'feComposite', + attrib={'in': 'SourceGraphic'}, + in2='BackgroundImage', + operator='arithmetic', + k2="1", k3="1") + writer.end('filter') + # feColorMatrix filter to correct opacity + writer.start( + 'filter', + id='colorMat') + writer.element( + 'feColorMatrix', + attrib={'type': 'matrix'}, + values='1 0 0 0 0 \n0 1 0 0 0 \n0 0 1 0 0 \n1 1 1 1 0 \n0 0 0 0 1 ') + writer.end('filter') + + for points, colors in zip(triangles_array, colors_array): + self._draw_gouraud_triangle(trans_and_flip.transform(points), colors) + writer.end('g') + + def option_scale_image(self): + # docstring inherited + return True + + def get_image_magnification(self): + return self.image_dpi / 72.0 + + def draw_image(self, gc, x, y, im, transform=None): + # docstring inherited + + h, w = im.shape[:2] + + if w == 0 or h == 0: + return + + clip_attrs = self._get_clip_attrs(gc) + if clip_attrs: + # Can't apply clip-path directly to the image because the image has + # a transformation, which would also be applied to the clip-path. + self.writer.start('g', **clip_attrs) + + url = gc.get_url() + if url is not None: + self.writer.start('a', attrib={'xlink:href': url}) + + attrib = {} + oid = gc.get_gid() + if mpl.rcParams['svg.image_inline']: + buf = BytesIO() + Image.fromarray(im).save(buf, format="png") + oid = oid or self._make_id('image', buf.getvalue()) + attrib['xlink:href'] = ( + "data:image/png;base64,\n" + + base64.b64encode(buf.getvalue()).decode('ascii')) + else: + if self.basename is None: + raise ValueError("Cannot save image data to filesystem when " + "writing SVG to an in-memory buffer") + filename = f'{self.basename}.image{next(self._image_counter)}.png' + _log.info('Writing image file for inclusion: %s', filename) + Image.fromarray(im).save(filename) + oid = oid or 'Im_' + self._make_id('image', filename) + attrib['xlink:href'] = filename + attrib['id'] = oid + + if transform is None: + w = 72.0 * w / self.image_dpi + h = 72.0 * h / self.image_dpi + + self.writer.element( + 'image', + transform=_generate_transform([ + ('scale', (1, -1)), ('translate', (0, -h))]), + x=_short_float_fmt(x), + y=_short_float_fmt(-(self.height - y - h)), + width=_short_float_fmt(w), height=_short_float_fmt(h), + attrib=attrib) + else: + alpha = gc.get_alpha() + if alpha != 1.0: + attrib['opacity'] = _short_float_fmt(alpha) + + flipped = ( + Affine2D().scale(1.0 / w, 1.0 / h) + + transform + + Affine2D() + .translate(x, y) + .scale(1.0, -1.0) + .translate(0.0, self.height)) + + attrib['transform'] = _generate_transform( + [('matrix', flipped.frozen())]) + attrib['style'] = ( + 'image-rendering:crisp-edges;' + 'image-rendering:pixelated') + self.writer.element( + 'image', + width=_short_float_fmt(w), height=_short_float_fmt(h), + attrib=attrib) + + if url is not None: + self.writer.end('a') + if clip_attrs: + self.writer.end('g') + + def _update_glyph_map_defs(self, glyph_map_new): + """ + Emit definitions for not-yet-defined glyphs, and record them as having + been defined. + """ + writer = self.writer + if glyph_map_new: + writer.start('defs') + for char_id, (vertices, codes) in glyph_map_new.items(): + char_id = self._adjust_char_id(char_id) + # x64 to go back to FreeType's internal (integral) units. + path_data = self._convert_path( + Path(vertices * 64, codes), simplify=False) + writer.element( + 'path', id=char_id, d=path_data, + transform=_generate_transform([('scale', (1 / 64,))])) + writer.end('defs') + self._glyph_map.update(glyph_map_new) + + def _adjust_char_id(self, char_id): + return char_id.replace("%20", "_") + + def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): + # docstring inherited + writer = self.writer + + writer.comment(s) + + glyph_map = self._glyph_map + + text2path = self._text2path + color = rgb2hex(gc.get_rgb()) + fontsize = prop.get_size_in_points() + + style = {} + if color != '#000000': + style['fill'] = color + alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3] + if alpha != 1: + style['opacity'] = _short_float_fmt(alpha) + font_scale = fontsize / text2path.FONT_SCALE + attrib = { + 'style': _generate_css(style), + 'transform': _generate_transform([ + ('translate', (x, y)), + ('rotate', (-angle,)), + ('scale', (font_scale, -font_scale))]), + } + writer.start('g', attrib=attrib) + + if not ismath: + font = text2path._get_font(prop) + _glyphs = text2path.get_glyphs_with_font( + font, s, glyph_map=glyph_map, return_new_glyphs_only=True) + glyph_info, glyph_map_new, rects = _glyphs + self._update_glyph_map_defs(glyph_map_new) + + for glyph_id, xposition, yposition, scale in glyph_info: + writer.element( + 'use', + transform=_generate_transform([ + ('translate', (xposition, yposition)), + ('scale', (scale,)), + ]), + attrib={'xlink:href': f'#{glyph_id}'}) + + else: + if ismath == "TeX": + _glyphs = text2path.get_glyphs_tex( + prop, s, glyph_map=glyph_map, return_new_glyphs_only=True) + else: + _glyphs = text2path.get_glyphs_mathtext( + prop, s, glyph_map=glyph_map, return_new_glyphs_only=True) + glyph_info, glyph_map_new, rects = _glyphs + self._update_glyph_map_defs(glyph_map_new) + + for char_id, xposition, yposition, scale in glyph_info: + char_id = self._adjust_char_id(char_id) + writer.element( + 'use', + transform=_generate_transform([ + ('translate', (xposition, yposition)), + ('scale', (scale,)), + ]), + attrib={'xlink:href': f'#{char_id}'}) + + for verts, codes in rects: + path = Path(verts, codes) + path_data = self._convert_path(path, simplify=False) + writer.element('path', d=path_data) + + writer.end('g') + + def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): + # NOTE: If you change the font styling CSS, then be sure the check for + # svg.fonttype = none in `lib/matplotlib/testing/compare.py::convert` remains in + # sync. Also be sure to re-generate any SVG using this mode, or else such tests + # will fail to use the right converter for the expected images, and they will + # fail strangely. + writer = self.writer + + color = rgb2hex(gc.get_rgb()) + font_style = {} + color_style = {} + if color != '#000000': + color_style['fill'] = color + + alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3] + if alpha != 1: + color_style['opacity'] = _short_float_fmt(alpha) + + if not ismath: + attrib = {} + + # Separate font style in their separate attributes + if prop.get_style() != 'normal': + font_style['font-style'] = prop.get_style() + if prop.get_variant() != 'normal': + font_style['font-variant'] = prop.get_variant() + weight = fm.weight_dict[prop.get_weight()] + if weight != 400: + font_style['font-weight'] = f'{weight}' + + def _normalize_sans(name): + return 'sans-serif' if name in ['sans', 'sans serif'] else name + + def _expand_family_entry(fn): + fn = _normalize_sans(fn) + # prepend generic font families with all configured font names + if fn in fm.font_family_aliases: + # get all of the font names and fix spelling of sans-serif + # (we accept 3 ways CSS only supports 1) + for name in fm.FontManager._expand_aliases(fn): + yield _normalize_sans(name) + # whether a generic name or a family name, it must appear at + # least once + yield fn + + def _get_all_quoted_names(prop): + # only quote specific names, not generic names + return [name if name in fm.font_family_aliases else repr(name) + for entry in prop.get_family() + for name in _expand_family_entry(entry)] + + font_style['font-size'] = f'{_short_float_fmt(prop.get_size())}px' + # ensure expansion, quoting, and dedupe of font names + font_style['font-family'] = ", ".join( + dict.fromkeys(_get_all_quoted_names(prop)) + ) + + if prop.get_stretch() != 'normal': + font_style['font-stretch'] = prop.get_stretch() + attrib['style'] = _generate_css({**font_style, **color_style}) + + if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"): + # If text anchoring can be supported, get the original + # coordinates and add alignment information. + + # Get anchor coordinates. + transform = mtext.get_transform() + ax, ay = transform.transform(mtext.get_unitless_position()) + ay = self.height - ay + + # Don't do vertical anchor alignment. Most applications do not + # support 'alignment-baseline' yet. Apply the vertical layout + # to the anchor point manually for now. + angle_rad = np.deg2rad(angle) + dir_vert = np.array([np.sin(angle_rad), np.cos(angle_rad)]) + v_offset = np.dot(dir_vert, [(x - ax), (y - ay)]) + ax = ax + v_offset * dir_vert[0] + ay = ay + v_offset * dir_vert[1] + + ha_mpl_to_svg = {'left': 'start', 'right': 'end', + 'center': 'middle'} + font_style['text-anchor'] = ha_mpl_to_svg[mtext.get_ha()] + + attrib['x'] = _short_float_fmt(ax) + attrib['y'] = _short_float_fmt(ay) + attrib['style'] = _generate_css({**font_style, **color_style}) + attrib['transform'] = _generate_transform([ + ("rotate", (-angle, ax, ay))]) + + else: + attrib['transform'] = _generate_transform([ + ('translate', (x, y)), + ('rotate', (-angle,))]) + + writer.element('text', s, attrib=attrib) + + else: + writer.comment(s) + + width, height, descent, glyphs, rects = \ + self._text2path.mathtext_parser.parse(s, 72, prop) + + # Apply attributes to 'g', not 'text', because we likely have some + # rectangles as well with the same style and transformation. + writer.start('g', + style=_generate_css({**font_style, **color_style}), + transform=_generate_transform([ + ('translate', (x, y)), + ('rotate', (-angle,))]), + ) + + writer.start('text') + + # Sort the characters by font, and output one tspan for each. + spans = {} + for font, fontsize, thetext, new_x, new_y in glyphs: + entry = fm.ttfFontProperty(font) + font_style = {} + # Separate font style in its separate attributes + if entry.style != 'normal': + font_style['font-style'] = entry.style + if entry.variant != 'normal': + font_style['font-variant'] = entry.variant + if entry.weight != 400: + font_style['font-weight'] = f'{entry.weight}' + font_style['font-size'] = f'{_short_float_fmt(fontsize)}px' + font_style['font-family'] = f'{entry.name!r}' # ensure quoting + if entry.stretch != 'normal': + font_style['font-stretch'] = entry.stretch + style = _generate_css({**font_style, **color_style}) + if thetext == 32: + thetext = 0xa0 # non-breaking space + spans.setdefault(style, []).append((new_x, -new_y, thetext)) + + for style, chars in spans.items(): + chars.sort() # Sort by increasing x position + for x, y, t in chars: # Output one tspan for each character + writer.element( + 'tspan', + chr(t), + x=_short_float_fmt(x), + y=_short_float_fmt(y), + style=style) + + writer.end('text') + + for x, y, width, height in rects: + writer.element( + 'rect', + x=_short_float_fmt(x), + y=_short_float_fmt(-y-1), + width=_short_float_fmt(width), + height=_short_float_fmt(height) + ) + + writer.end('g') + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + # docstring inherited + + clip_attrs = self._get_clip_attrs(gc) + if clip_attrs: + # Cannot apply clip-path directly to the text, because + # it has a transformation + self.writer.start('g', **clip_attrs) + + if gc.get_url() is not None: + self.writer.start('a', {'xlink:href': gc.get_url()}) + + if mpl.rcParams['svg.fonttype'] == 'path': + self._draw_text_as_path(gc, x, y, s, prop, angle, ismath, mtext) + else: + self._draw_text_as_text(gc, x, y, s, prop, angle, ismath, mtext) + + if gc.get_url() is not None: + self.writer.end('a') + + if clip_attrs: + self.writer.end('g') + + def flipy(self): + # docstring inherited + return True + + def get_canvas_width_height(self): + # docstring inherited + return self.width, self.height + + def get_text_width_height_descent(self, s, prop, ismath): + # docstring inherited + return self._text2path.get_text_width_height_descent(s, prop, ismath) + + +class FigureCanvasSVG(FigureCanvasBase): + filetypes = {'svg': 'Scalable Vector Graphics', + 'svgz': 'Scalable Vector Graphics'} + + fixed_dpi = 72 + + def print_svg(self, filename, *, bbox_inches_restore=None, metadata=None): + """ + Parameters + ---------- + filename : str or path-like or file-like + Output target; if a string, a file will be opened for writing. + + metadata : dict[str, Any], optional + Metadata in the SVG file defined as key-value pairs of strings, + datetimes, or lists of strings, e.g., ``{'Creator': 'My software', + 'Contributor': ['Me', 'My Friend'], 'Title': 'Awesome'}``. + + The standard keys and their value types are: + + * *str*: ``'Coverage'``, ``'Description'``, ``'Format'``, + ``'Identifier'``, ``'Language'``, ``'Relation'``, ``'Source'``, + ``'Title'``, and ``'Type'``. + * *str* or *list of str*: ``'Contributor'``, ``'Creator'``, + ``'Keywords'``, ``'Publisher'``, and ``'Rights'``. + * *str*, *date*, *datetime*, or *tuple* of same: ``'Date'``. If a + non-*str*, then it will be formatted as ISO 8601. + + Values have been predefined for ``'Creator'``, ``'Date'``, + ``'Format'``, and ``'Type'``. They can be removed by setting them + to `None`. + + Information is encoded as `Dublin Core Metadata`__. + + .. _DC: https://www.dublincore.org/specifications/dublin-core/ + + __ DC_ + """ + with cbook.open_file_cm(filename, "w", encoding="utf-8") as fh: + if not cbook.file_requires_unicode(fh): + fh = codecs.getwriter('utf-8')(fh) + dpi = self.figure.dpi + self.figure.dpi = 72 + width, height = self.figure.get_size_inches() + w, h = width * 72, height * 72 + renderer = MixedModeRenderer( + self.figure, width, height, dpi, + RendererSVG(w, h, fh, image_dpi=dpi, metadata=metadata), + bbox_inches_restore=bbox_inches_restore) + self.figure.draw(renderer) + renderer.finalize() + + def print_svgz(self, filename, **kwargs): + with (cbook.open_file_cm(filename, "wb") as fh, + gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter): + return self.print_svg(gzipwriter, **kwargs) + + def get_default_filetype(self): + return 'svg' + + def draw(self): + self.figure.draw_without_rendering() + return super().draw() + + +FigureManagerSVG = FigureManagerBase + + +svgProlog = """\ + + +""" + + +@_Backend.export +class _BackendSVG(_Backend): + backend_version = mpl.__version__ + FigureCanvas = FigureCanvasSVG diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_tkcairo.py b/lib/python3.12/site-packages/matplotlib/backends/backend_tkcairo.py new file mode 100644 index 0000000000000000000000000000000000000000..a6951c03c65a328bc9c46a369aed65ad7df259bb --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_tkcairo.py @@ -0,0 +1,26 @@ +import sys + +import numpy as np + +from . import _backend_tk +from .backend_cairo import cairo, FigureCanvasCairo +from ._backend_tk import _BackendTk, FigureCanvasTk + + +class FigureCanvasTkCairo(FigureCanvasCairo, FigureCanvasTk): + def draw(self): + width = int(self.figure.bbox.width) + height = int(self.figure.bbox.height) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + self._renderer.set_context(cairo.Context(surface)) + self._renderer.dpi = self.figure.dpi + self.figure.draw(self._renderer) + buf = np.reshape(surface.get_data(), (height, width, 4)) + _backend_tk.blit( + self._tkphoto, buf, + (2, 1, 0, 3) if sys.byteorder == "little" else (1, 2, 3, 0)) + + +@_BackendTk.export +class _BackendTkCairo(_BackendTk): + FigureCanvas = FigureCanvasTkCairo diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_webagg.py b/lib/python3.12/site-packages/matplotlib/backends/backend_webagg.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc5747ef77cb4123d1a8d35657f22952d378df2 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_webagg.py @@ -0,0 +1,328 @@ +"""Displays Agg images in the browser, with interactivity.""" + +# The WebAgg backend is divided into two modules: +# +# - `backend_webagg_core.py` contains code necessary to embed a WebAgg +# plot inside of a web application, and communicate in an abstract +# way over a web socket. +# +# - `backend_webagg.py` contains a concrete implementation of a basic +# application, implemented with tornado. + +from contextlib import contextmanager +import errno +from io import BytesIO +import json +import mimetypes +from pathlib import Path +import random +import sys +import signal +import threading + +try: + import tornado +except ImportError as err: + raise RuntimeError("The WebAgg backend requires Tornado.") from err + +import tornado.web +import tornado.ioloop +import tornado.websocket + +import matplotlib as mpl +from matplotlib.backend_bases import _Backend +from matplotlib._pylab_helpers import Gcf +from . import backend_webagg_core as core +from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611 + TimerAsyncio, TimerTornado) + + +webagg_server_thread = threading.Thread( + target=lambda: tornado.ioloop.IOLoop.instance().start()) + + +class FigureManagerWebAgg(core.FigureManagerWebAgg): + _toolbar2_class = core.NavigationToolbar2WebAgg + + @classmethod + def pyplot_show(cls, *, block=None): + WebAggApplication.initialize() + + url = "http://{address}:{port}{prefix}".format( + address=WebAggApplication.address, + port=WebAggApplication.port, + prefix=WebAggApplication.url_prefix) + + if mpl.rcParams['webagg.open_in_browser']: + import webbrowser + if not webbrowser.open(url): + print(f"To view figure, visit {url}") + else: + print(f"To view figure, visit {url}") + + WebAggApplication.start() + + +class FigureCanvasWebAgg(core.FigureCanvasWebAggCore): + manager_class = FigureManagerWebAgg + + +class WebAggApplication(tornado.web.Application): + initialized = False + started = False + + class FavIcon(tornado.web.RequestHandler): + def get(self): + self.set_header('Content-Type', 'image/png') + self.write(Path(mpl.get_data_path(), + 'images/matplotlib.png').read_bytes()) + + class SingleFigurePage(tornado.web.RequestHandler): + def __init__(self, application, request, *, url_prefix='', **kwargs): + self.url_prefix = url_prefix + super().__init__(application, request, **kwargs) + + def get(self, fignum): + fignum = int(fignum) + manager = Gcf.get_fig_manager(fignum) + + ws_uri = f'ws://{self.request.host}{self.url_prefix}/' + self.render( + "single_figure.html", + prefix=self.url_prefix, + ws_uri=ws_uri, + fig_id=fignum, + toolitems=core.NavigationToolbar2WebAgg.toolitems, + canvas=manager.canvas) + + class AllFiguresPage(tornado.web.RequestHandler): + def __init__(self, application, request, *, url_prefix='', **kwargs): + self.url_prefix = url_prefix + super().__init__(application, request, **kwargs) + + def get(self): + ws_uri = f'ws://{self.request.host}{self.url_prefix}/' + self.render( + "all_figures.html", + prefix=self.url_prefix, + ws_uri=ws_uri, + figures=sorted(Gcf.figs.items()), + toolitems=core.NavigationToolbar2WebAgg.toolitems) + + class MplJs(tornado.web.RequestHandler): + def get(self): + self.set_header('Content-Type', 'application/javascript') + + js_content = core.FigureManagerWebAgg.get_javascript() + + self.write(js_content) + + class Download(tornado.web.RequestHandler): + def get(self, fignum, fmt): + fignum = int(fignum) + manager = Gcf.get_fig_manager(fignum) + self.set_header( + 'Content-Type', mimetypes.types_map.get(fmt, 'binary')) + buff = BytesIO() + manager.canvas.figure.savefig(buff, format=fmt) + self.write(buff.getvalue()) + + class WebSocket(tornado.websocket.WebSocketHandler): + supports_binary = True + + def open(self, fignum): + self.fignum = int(fignum) + self.manager = Gcf.get_fig_manager(self.fignum) + self.manager.add_web_socket(self) + if hasattr(self, 'set_nodelay'): + self.set_nodelay(True) + + def on_close(self): + self.manager.remove_web_socket(self) + + def on_message(self, message): + message = json.loads(message) + # The 'supports_binary' message is on a client-by-client + # basis. The others affect the (shared) canvas as a + # whole. + if message['type'] == 'supports_binary': + self.supports_binary = message['value'] + else: + manager = Gcf.get_fig_manager(self.fignum) + # It is possible for a figure to be closed, + # but a stale figure UI is still sending messages + # from the browser. + if manager is not None: + manager.handle_json(message) + + def send_json(self, content): + self.write_message(json.dumps(content)) + + def send_binary(self, blob): + if self.supports_binary: + self.write_message(blob, binary=True) + else: + data_uri = "data:image/png;base64,{}".format( + blob.encode('base64').replace('\n', '')) + self.write_message(data_uri) + + def __init__(self, url_prefix=''): + if url_prefix: + assert url_prefix[0] == '/' and url_prefix[-1] != '/', \ + 'url_prefix must start with a "/" and not end with one.' + + super().__init__( + [ + # Static files for the CSS and JS + (url_prefix + r'/_static/(.*)', + tornado.web.StaticFileHandler, + {'path': core.FigureManagerWebAgg.get_static_file_path()}), + + # Static images for the toolbar + (url_prefix + r'/_images/(.*)', + tornado.web.StaticFileHandler, + {'path': Path(mpl.get_data_path(), 'images')}), + + # A Matplotlib favicon + (url_prefix + r'/favicon.ico', self.FavIcon), + + # The page that contains all of the pieces + (url_prefix + r'/([0-9]+)', self.SingleFigurePage, + {'url_prefix': url_prefix}), + + # The page that contains all of the figures + (url_prefix + r'/?', self.AllFiguresPage, + {'url_prefix': url_prefix}), + + (url_prefix + r'/js/mpl.js', self.MplJs), + + # Sends images and events to the browser, and receives + # events from the browser + (url_prefix + r'/([0-9]+)/ws', self.WebSocket), + + # Handles the downloading (i.e., saving) of static images + (url_prefix + r'/([0-9]+)/download.([a-z0-9.]+)', + self.Download), + ], + template_path=core.FigureManagerWebAgg.get_static_file_path()) + + @classmethod + def initialize(cls, url_prefix='', port=None, address=None): + if cls.initialized: + return + + # Create the class instance + app = cls(url_prefix=url_prefix) + + cls.url_prefix = url_prefix + + # This port selection algorithm is borrowed, more or less + # verbatim, from IPython. + def random_ports(port, n): + """ + Generate a list of n random ports near the given port. + + The first 5 ports will be sequential, and the remaining n-5 will be + randomly selected in the range [port-2*n, port+2*n]. + """ + for i in range(min(5, n)): + yield port + i + for i in range(n - 5): + yield port + random.randint(-2 * n, 2 * n) + + if address is None: + cls.address = mpl.rcParams['webagg.address'] + else: + cls.address = address + cls.port = mpl.rcParams['webagg.port'] + for port in random_ports(cls.port, + mpl.rcParams['webagg.port_retries']): + try: + app.listen(port, cls.address) + except OSError as e: + if e.errno != errno.EADDRINUSE: + raise + else: + cls.port = port + break + else: + raise SystemExit( + "The webagg server could not be started because an available " + "port could not be found") + + cls.initialized = True + + @classmethod + def start(cls): + import asyncio + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + cls.started = True + + if cls.started: + return + + """ + IOLoop.running() was removed as of Tornado 2.4; see for example + https://groups.google.com/forum/#!topic/python-tornado/QLMzkpQBGOY + Thus there is no correct way to check if the loop has already been + launched. We may end up with two concurrently running loops in that + unlucky case with all the expected consequences. + """ + ioloop = tornado.ioloop.IOLoop.instance() + + def shutdown(): + ioloop.stop() + print("Server is stopped") + sys.stdout.flush() + cls.started = False + + @contextmanager + def catch_sigint(): + old_handler = signal.signal( + signal.SIGINT, + lambda sig, frame: ioloop.add_callback_from_signal(shutdown)) + try: + yield + finally: + signal.signal(signal.SIGINT, old_handler) + + # Set the flag to True *before* blocking on ioloop.start() + cls.started = True + + print("Press Ctrl+C to stop WebAgg server") + sys.stdout.flush() + with catch_sigint(): + ioloop.start() + + +def ipython_inline_display(figure): + import tornado.template + + WebAggApplication.initialize() + import asyncio + try: + asyncio.get_running_loop() + except RuntimeError: + if not webagg_server_thread.is_alive(): + webagg_server_thread.start() + + fignum = figure.number + tpl = Path(core.FigureManagerWebAgg.get_static_file_path(), + "ipython_inline_figure.html").read_text() + t = tornado.template.Template(tpl) + return t.generate( + prefix=WebAggApplication.url_prefix, + fig_id=fignum, + toolitems=core.NavigationToolbar2WebAgg.toolitems, + canvas=figure.canvas, + port=WebAggApplication.port).decode('utf-8') + + +@_Backend.export +class _BackendWebAgg(_Backend): + FigureCanvas = FigureCanvasWebAgg + FigureManager = FigureManagerWebAgg diff --git a/lib/python3.12/site-packages/matplotlib/backends/backend_wxcairo.py b/lib/python3.12/site-packages/matplotlib/backends/backend_wxcairo.py new file mode 100644 index 0000000000000000000000000000000000000000..c53e6af4b87390455d02fab1bd3060648cac7fe8 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/backend_wxcairo.py @@ -0,0 +1,23 @@ +import wx.lib.wxcairo as wxcairo + +from .backend_cairo import cairo, FigureCanvasCairo +from .backend_wx import _BackendWx, _FigureCanvasWxBase +from .backend_wx import ( # noqa: F401 # pylint: disable=W0611 + NavigationToolbar2Wx as NavigationToolbar2WxCairo) + + +class FigureCanvasWxCairo(FigureCanvasCairo, _FigureCanvasWxBase): + def draw(self, drawDC=None): + size = self.figure.bbox.size.astype(int) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *size) + self._renderer.set_context(cairo.Context(surface)) + self._renderer.dpi = self.figure.dpi + self.figure.draw(self._renderer) + self.bitmap = wxcairo.BitmapFromImageSurface(surface) + self._isDrawn = True + self.gui_repaint(drawDC=drawDC) + + +@_BackendWx.export +class _BackendWxCairo(_BackendWx): + FigureCanvas = FigureCanvasWxCairo diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_compat.py b/lib/python3.12/site-packages/matplotlib/backends/qt_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..b57a98b1138aa60bf4ae55d4fe5e5da44f068306 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/qt_compat.py @@ -0,0 +1,159 @@ +""" +Qt binding and backend selector. + +The selection logic is as follows: +- if any of PyQt6, PySide6, PyQt5, or PySide2 have already been + imported (checked in that order), use it; +- otherwise, if the QT_API environment variable (used by Enthought) is set, use + it to determine which binding to use; +- otherwise, use whatever the rcParams indicate. +""" + +import operator +import os +import platform +import sys + +from packaging.version import parse as parse_version + +import matplotlib as mpl + +from . import _QT_FORCE_QT5_BINDING + +QT_API_PYQT6 = "PyQt6" +QT_API_PYSIDE6 = "PySide6" +QT_API_PYQT5 = "PyQt5" +QT_API_PYSIDE2 = "PySide2" +QT_API_ENV = os.environ.get("QT_API") +if QT_API_ENV is not None: + QT_API_ENV = QT_API_ENV.lower() +_ETS = { # Mapping of QT_API_ENV to requested binding. + "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6, + "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, +} +# First, check if anything is already imported. +if sys.modules.get("PyQt6.QtCore"): + QT_API = QT_API_PYQT6 +elif sys.modules.get("PySide6.QtCore"): + QT_API = QT_API_PYSIDE6 +elif sys.modules.get("PyQt5.QtCore"): + QT_API = QT_API_PYQT5 +elif sys.modules.get("PySide2.QtCore"): + QT_API = QT_API_PYSIDE2 +# Otherwise, check the QT_API environment variable (from Enthought). This can +# only override the binding, not the backend (in other words, we check that the +# requested backend actually matches). Use _get_backend_or_none to avoid +# triggering backend resolution (which can result in a partially but +# incompletely imported backend_qt5). +elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"): + if QT_API_ENV in ["pyqt5", "pyside2"]: + QT_API = _ETS[QT_API_ENV] + else: + _QT_FORCE_QT5_BINDING = True # noqa: F811 + QT_API = None +# A non-Qt backend was selected but we still got there (possible, e.g., when +# fully manually embedding Matplotlib in a Qt app without using pyplot). +elif QT_API_ENV is None: + QT_API = None +elif QT_API_ENV in _ETS: + QT_API = _ETS[QT_API_ENV] +else: + raise RuntimeError( + "The environment variable QT_API has the unrecognized value {!r}; " + "valid values are {}".format(QT_API_ENV, ", ".join(_ETS))) + + +def _setup_pyqt5plus(): + global QtCore, QtGui, QtWidgets, __version__ + global _isdeleted, _to_int + + if QT_API == QT_API_PYQT6: + from PyQt6 import QtCore, QtGui, QtWidgets, sip + __version__ = QtCore.PYQT_VERSION_STR + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + _isdeleted = sip.isdeleted + _to_int = operator.attrgetter('value') + elif QT_API == QT_API_PYSIDE6: + from PySide6 import QtCore, QtGui, QtWidgets, __version__ + import shiboken6 + def _isdeleted(obj): return not shiboken6.isValid(obj) + if parse_version(__version__) >= parse_version('6.4'): + _to_int = operator.attrgetter('value') + else: + _to_int = int + elif QT_API == QT_API_PYQT5: + from PyQt5 import QtCore, QtGui, QtWidgets + import sip + __version__ = QtCore.PYQT_VERSION_STR + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + _isdeleted = sip.isdeleted + _to_int = int + elif QT_API == QT_API_PYSIDE2: + from PySide2 import QtCore, QtGui, QtWidgets, __version__ + try: + from PySide2 import shiboken2 + except ImportError: + import shiboken2 + def _isdeleted(obj): + return not shiboken2.isValid(obj) + _to_int = int + else: + raise AssertionError(f"Unexpected QT_API: {QT_API}") + + +if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]: + _setup_pyqt5plus() +elif QT_API is None: # See above re: dict.__getitem__. + if _QT_FORCE_QT5_BINDING: + _candidates = [ + (_setup_pyqt5plus, QT_API_PYQT5), + (_setup_pyqt5plus, QT_API_PYSIDE2), + ] + else: + _candidates = [ + (_setup_pyqt5plus, QT_API_PYQT6), + (_setup_pyqt5plus, QT_API_PYSIDE6), + (_setup_pyqt5plus, QT_API_PYQT5), + (_setup_pyqt5plus, QT_API_PYSIDE2), + ] + for _setup, QT_API in _candidates: + try: + _setup() + except ImportError: + continue + break + else: + raise ImportError( + "Failed to import any of the following Qt binding modules: {}" + .format(", ".join([QT_API for _, QT_API in _candidates])) + ) +else: # We should not get there. + raise AssertionError(f"Unexpected QT_API: {QT_API}") +_version_info = tuple(QtCore.QLibraryInfo.version().segments()) + + +if _version_info < (5, 12): + raise ImportError( + f"The Qt version imported is " + f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires " + f"Qt>=5.12") + + +# Fixes issues with Big Sur +# https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2 +if (sys.platform == 'darwin' and + parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and + _version_info < (5, 15, 2)): + os.environ.setdefault("QT_MAC_WANTS_LAYER", "1") + + +# Backports. + + +def _exec(obj): + # exec on PyQt6, exec_ elsewhere. + obj.exec() if hasattr(obj, "exec") else obj.exec_() diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__init__.py b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67609fc7292011227576c23c372ddb2785a3f592 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/_formlayout.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/_formlayout.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75cbf4dfc01b4df51cca17b6f5f163ea14e3d6a7 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/_formlayout.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/figureoptions.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/figureoptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc40c87778ef83b2ba12d8eaeb135bb55096832a Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/__pycache__/figureoptions.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/_formlayout.py b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/_formlayout.py new file mode 100644 index 0000000000000000000000000000000000000000..fcf73cefbac8bc41c2f02f7895706423d3676e96 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/_formlayout.py @@ -0,0 +1,592 @@ +""" +formlayout +========== + +Module creating Qt form dialogs/layouts to edit various type of parameters + + +formlayout License Agreement (MIT License) +------------------------------------------ + +Copyright (c) 2009 Pierre Raybaut + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. +""" + +# History: +# 1.0.10: added float validator +# (disable "Ok" and "Apply" button when not valid) +# 1.0.7: added support for "Apply" button +# 1.0.6: code cleaning + +__version__ = '1.0.10' +__license__ = __doc__ + +from ast import literal_eval + +import copy +import datetime +import logging +from numbers import Integral, Real + +from matplotlib import _api, colors as mcolors +from matplotlib.backends.qt_compat import _to_int, QtGui, QtWidgets, QtCore + +_log = logging.getLogger(__name__) + +BLACKLIST = {"title", "label"} + + +class ColorButton(QtWidgets.QPushButton): + """ + Color choosing push button + """ + colorChanged = QtCore.Signal(QtGui.QColor) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(20, 20) + self.setIconSize(QtCore.QSize(12, 12)) + self.clicked.connect(self.choose_color) + self._color = QtGui.QColor() + + def choose_color(self): + color = QtWidgets.QColorDialog.getColor( + self._color, self.parentWidget(), "", + QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel) + if color.isValid(): + self.set_color(color) + + def get_color(self): + return self._color + + @QtCore.Slot(QtGui.QColor) + def set_color(self, color): + if color != self._color: + self._color = color + self.colorChanged.emit(self._color) + pixmap = QtGui.QPixmap(self.iconSize()) + pixmap.fill(color) + self.setIcon(QtGui.QIcon(pixmap)) + + color = QtCore.Property(QtGui.QColor, get_color, set_color) + + +def to_qcolor(color): + """Create a QColor from a matplotlib color""" + qcolor = QtGui.QColor() + try: + rgba = mcolors.to_rgba(color) + except ValueError: + _api.warn_external(f'Ignoring invalid color {color!r}') + return qcolor # return invalid QColor + qcolor.setRgbF(*rgba) + return qcolor + + +class ColorLayout(QtWidgets.QHBoxLayout): + """Color-specialized QLineEdit layout""" + def __init__(self, color, parent=None): + super().__init__() + assert isinstance(color, QtGui.QColor) + self.lineedit = QtWidgets.QLineEdit( + mcolors.to_hex(color.getRgbF(), keep_alpha=True), parent) + self.lineedit.editingFinished.connect(self.update_color) + self.addWidget(self.lineedit) + self.colorbtn = ColorButton(parent) + self.colorbtn.color = color + self.colorbtn.colorChanged.connect(self.update_text) + self.addWidget(self.colorbtn) + + def update_color(self): + color = self.text() + qcolor = to_qcolor(color) # defaults to black if not qcolor.isValid() + self.colorbtn.color = qcolor + + def update_text(self, color): + self.lineedit.setText(mcolors.to_hex(color.getRgbF(), keep_alpha=True)) + + def text(self): + return self.lineedit.text() + + +def font_is_installed(font): + """Check if font is installed""" + return [fam for fam in QtGui.QFontDatabase().families() + if str(fam) == font] + + +def tuple_to_qfont(tup): + """ + Create a QFont from tuple: + (family [string], size [int], italic [bool], bold [bool]) + """ + if not (isinstance(tup, tuple) and len(tup) == 4 + and font_is_installed(tup[0]) + and isinstance(tup[1], Integral) + and isinstance(tup[2], bool) + and isinstance(tup[3], bool)): + return None + font = QtGui.QFont() + family, size, italic, bold = tup + font.setFamily(family) + font.setPointSize(size) + font.setItalic(italic) + font.setBold(bold) + return font + + +def qfont_to_tuple(font): + return (str(font.family()), int(font.pointSize()), + font.italic(), font.bold()) + + +class FontLayout(QtWidgets.QGridLayout): + """Font selection""" + def __init__(self, value, parent=None): + super().__init__() + font = tuple_to_qfont(value) + assert font is not None + + # Font family + self.family = QtWidgets.QFontComboBox(parent) + self.family.setCurrentFont(font) + self.addWidget(self.family, 0, 0, 1, -1) + + # Font size + self.size = QtWidgets.QComboBox(parent) + self.size.setEditable(True) + sizelist = [*range(6, 12), *range(12, 30, 2), 36, 48, 72] + size = font.pointSize() + if size not in sizelist: + sizelist.append(size) + sizelist.sort() + self.size.addItems([str(s) for s in sizelist]) + self.size.setCurrentIndex(sizelist.index(size)) + self.addWidget(self.size, 1, 0) + + # Italic or not + self.italic = QtWidgets.QCheckBox(self.tr("Italic"), parent) + self.italic.setChecked(font.italic()) + self.addWidget(self.italic, 1, 1) + + # Bold or not + self.bold = QtWidgets.QCheckBox(self.tr("Bold"), parent) + self.bold.setChecked(font.bold()) + self.addWidget(self.bold, 1, 2) + + def get_font(self): + font = self.family.currentFont() + font.setItalic(self.italic.isChecked()) + font.setBold(self.bold.isChecked()) + font.setPointSize(int(self.size.currentText())) + return qfont_to_tuple(font) + + +def is_edit_valid(edit): + text = edit.text() + state = edit.validator().validate(text, 0)[0] + return state == QtGui.QDoubleValidator.State.Acceptable + + +class FormWidget(QtWidgets.QWidget): + update_buttons = QtCore.Signal() + + def __init__(self, data, comment="", with_margin=False, parent=None): + """ + Parameters + ---------- + data : list of (label, value) pairs + The data to be edited in the form. + comment : str, optional + with_margin : bool, default: False + If False, the form elements reach to the border of the widget. + This is the desired behavior if the FormWidget is used as a widget + alongside with other widgets such as a QComboBox, which also do + not have a margin around them. + However, a margin can be desired if the FormWidget is the only + widget within a container, e.g. a tab in a QTabWidget. + parent : QWidget or None + The parent widget. + """ + super().__init__(parent) + self.data = copy.deepcopy(data) + self.widgets = [] + self.formlayout = QtWidgets.QFormLayout(self) + if not with_margin: + self.formlayout.setContentsMargins(0, 0, 0, 0) + if comment: + self.formlayout.addRow(QtWidgets.QLabel(comment)) + self.formlayout.addRow(QtWidgets.QLabel(" ")) + + def get_dialog(self): + """Return FormDialog instance""" + dialog = self.parent() + while not isinstance(dialog, QtWidgets.QDialog): + dialog = dialog.parent() + return dialog + + def setup(self): + for label, value in self.data: + if label is None and value is None: + # Separator: (None, None) + self.formlayout.addRow(QtWidgets.QLabel(" "), + QtWidgets.QLabel(" ")) + self.widgets.append(None) + continue + elif label is None: + # Comment + self.formlayout.addRow(QtWidgets.QLabel(value)) + self.widgets.append(None) + continue + elif tuple_to_qfont(value) is not None: + field = FontLayout(value, self) + elif (label.lower() not in BLACKLIST + and mcolors.is_color_like(value)): + field = ColorLayout(to_qcolor(value), self) + elif isinstance(value, str): + field = QtWidgets.QLineEdit(value, self) + elif isinstance(value, (list, tuple)): + if isinstance(value, tuple): + value = list(value) + # Note: get() below checks the type of value[0] in self.data so + # it is essential that value gets modified in-place. + # This means that the code is actually broken in the case where + # value is a tuple, but fortunately we always pass a list... + selindex = value.pop(0) + field = QtWidgets.QComboBox(self) + if isinstance(value[0], (list, tuple)): + keys = [key for key, _val in value] + value = [val for _key, val in value] + else: + keys = value + field.addItems(value) + if selindex in value: + selindex = value.index(selindex) + elif selindex in keys: + selindex = keys.index(selindex) + elif not isinstance(selindex, Integral): + _log.warning( + "index '%s' is invalid (label: %s, value: %s)", + selindex, label, value) + selindex = 0 + field.setCurrentIndex(selindex) + elif isinstance(value, bool): + field = QtWidgets.QCheckBox(self) + field.setChecked(value) + elif isinstance(value, Integral): + field = QtWidgets.QSpinBox(self) + field.setRange(-10**9, 10**9) + field.setValue(value) + elif isinstance(value, Real): + field = QtWidgets.QLineEdit(repr(value), self) + field.setCursorPosition(0) + field.setValidator(QtGui.QDoubleValidator(field)) + field.validator().setLocale(QtCore.QLocale("C")) + dialog = self.get_dialog() + dialog.register_float_field(field) + field.textChanged.connect(lambda text: dialog.update_buttons()) + elif isinstance(value, datetime.datetime): + field = QtWidgets.QDateTimeEdit(self) + field.setDateTime(value) + elif isinstance(value, datetime.date): + field = QtWidgets.QDateEdit(self) + field.setDate(value) + else: + field = QtWidgets.QLineEdit(repr(value), self) + self.formlayout.addRow(label, field) + self.widgets.append(field) + + def get(self): + valuelist = [] + for index, (label, value) in enumerate(self.data): + field = self.widgets[index] + if label is None: + # Separator / Comment + continue + elif tuple_to_qfont(value) is not None: + value = field.get_font() + elif isinstance(value, str) or mcolors.is_color_like(value): + value = str(field.text()) + elif isinstance(value, (list, tuple)): + index = int(field.currentIndex()) + if isinstance(value[0], (list, tuple)): + value = value[index][0] + else: + value = value[index] + elif isinstance(value, bool): + value = field.isChecked() + elif isinstance(value, Integral): + value = int(field.value()) + elif isinstance(value, Real): + value = float(str(field.text())) + elif isinstance(value, datetime.datetime): + datetime_ = field.dateTime() + if hasattr(datetime_, "toPyDateTime"): + value = datetime_.toPyDateTime() + else: + value = datetime_.toPython() + elif isinstance(value, datetime.date): + date_ = field.date() + if hasattr(date_, "toPyDate"): + value = date_.toPyDate() + else: + value = date_.toPython() + else: + value = literal_eval(str(field.text())) + valuelist.append(value) + return valuelist + + +class FormComboWidget(QtWidgets.QWidget): + update_buttons = QtCore.Signal() + + def __init__(self, datalist, comment="", parent=None): + super().__init__(parent) + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + self.combobox = QtWidgets.QComboBox() + layout.addWidget(self.combobox) + + self.stackwidget = QtWidgets.QStackedWidget(self) + layout.addWidget(self.stackwidget) + self.combobox.currentIndexChanged.connect( + self.stackwidget.setCurrentIndex) + + self.widgetlist = [] + for data, title, comment in datalist: + self.combobox.addItem(title) + widget = FormWidget(data, comment=comment, parent=self) + self.stackwidget.addWidget(widget) + self.widgetlist.append(widget) + + def setup(self): + for widget in self.widgetlist: + widget.setup() + + def get(self): + return [widget.get() for widget in self.widgetlist] + + +class FormTabWidget(QtWidgets.QWidget): + update_buttons = QtCore.Signal() + + def __init__(self, datalist, comment="", parent=None): + super().__init__(parent) + layout = QtWidgets.QVBoxLayout() + self.tabwidget = QtWidgets.QTabWidget() + layout.addWidget(self.tabwidget) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + self.widgetlist = [] + for data, title, comment in datalist: + if len(data[0]) == 3: + widget = FormComboWidget(data, comment=comment, parent=self) + else: + widget = FormWidget(data, with_margin=True, comment=comment, + parent=self) + index = self.tabwidget.addTab(widget, title) + self.tabwidget.setTabToolTip(index, comment) + self.widgetlist.append(widget) + + def setup(self): + for widget in self.widgetlist: + widget.setup() + + def get(self): + return [widget.get() for widget in self.widgetlist] + + +class FormDialog(QtWidgets.QDialog): + """Form Dialog""" + def __init__(self, data, title="", comment="", + icon=None, parent=None, apply=None): + super().__init__(parent) + + self.apply_callback = apply + + # Form + if isinstance(data[0][0], (list, tuple)): + self.formwidget = FormTabWidget(data, comment=comment, + parent=self) + elif len(data[0]) == 3: + self.formwidget = FormComboWidget(data, comment=comment, + parent=self) + else: + self.formwidget = FormWidget(data, comment=comment, + parent=self) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.formwidget) + + self.float_fields = [] + self.formwidget.setup() + + # Button box + self.bbox = bbox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton( + _to_int(QtWidgets.QDialogButtonBox.StandardButton.Ok) | + _to_int(QtWidgets.QDialogButtonBox.StandardButton.Cancel) + )) + self.formwidget.update_buttons.connect(self.update_buttons) + if self.apply_callback is not None: + apply_btn = bbox.addButton( + QtWidgets.QDialogButtonBox.StandardButton.Apply) + apply_btn.clicked.connect(self.apply) + + bbox.accepted.connect(self.accept) + bbox.rejected.connect(self.reject) + layout.addWidget(bbox) + + self.setLayout(layout) + + self.setWindowTitle(title) + if not isinstance(icon, QtGui.QIcon): + icon = QtWidgets.QWidget().style().standardIcon( + QtWidgets.QStyle.SP_MessageBoxQuestion) + self.setWindowIcon(icon) + + def register_float_field(self, field): + self.float_fields.append(field) + + def update_buttons(self): + valid = True + for field in self.float_fields: + if not is_edit_valid(field): + valid = False + for btn_type in ["Ok", "Apply"]: + btn = self.bbox.button( + getattr(QtWidgets.QDialogButtonBox.StandardButton, + btn_type)) + if btn is not None: + btn.setEnabled(valid) + + def accept(self): + self.data = self.formwidget.get() + self.apply_callback(self.data) + super().accept() + + def reject(self): + self.data = None + super().reject() + + def apply(self): + self.apply_callback(self.formwidget.get()) + + def get(self): + """Return form result""" + return self.data + + +def fedit(data, title="", comment="", icon=None, parent=None, apply=None): + """ + Create form dialog + + data: datalist, datagroup + title: str + comment: str + icon: QIcon instance + parent: parent QWidget + apply: apply callback (function) + + datalist: list/tuple of (field_name, field_value) + datagroup: list/tuple of (datalist *or* datagroup, title, comment) + + -> one field for each member of a datalist + -> one tab for each member of a top-level datagroup + -> one page (of a multipage widget, each page can be selected with a combo + box) for each member of a datagroup inside a datagroup + + Supported types for field_value: + - int, float, str, bool + - colors: in Qt-compatible text form, i.e. in hex format or name + (red, ...) (automatically detected from a string) + - list/tuple: + * the first element will be the selected index (or value) + * the other elements can be couples (key, value) or only values + """ + + # Create a QApplication instance if no instance currently exists + # (e.g., if the module is used directly from the interpreter) + if QtWidgets.QApplication.startingUp(): + _app = QtWidgets.QApplication([]) + dialog = FormDialog(data, title, comment, icon, parent, apply) + + if parent is not None: + if hasattr(parent, "_fedit_dialog"): + parent._fedit_dialog.close() + parent._fedit_dialog = dialog + + dialog.show() + + +if __name__ == "__main__": + + _app = QtWidgets.QApplication([]) + + def create_datalist_example(): + return [('str', 'this is a string'), + ('list', [0, '1', '3', '4']), + ('list2', ['--', ('none', 'None'), ('--', 'Dashed'), + ('-.', 'DashDot'), ('-', 'Solid'), + ('steps', 'Steps'), (':', 'Dotted')]), + ('float', 1.2), + (None, 'Other:'), + ('int', 12), + ('font', ('Arial', 10, False, True)), + ('color', '#123409'), + ('bool', True), + ('date', datetime.date(2010, 10, 10)), + ('datetime', datetime.datetime(2010, 10, 10)), + ] + + def create_datagroup_example(): + datalist = create_datalist_example() + return ((datalist, "Category 1", "Category 1 comment"), + (datalist, "Category 2", "Category 2 comment"), + (datalist, "Category 3", "Category 3 comment")) + + # --------- datalist example + datalist = create_datalist_example() + + def apply_test(data): + print("data:", data) + fedit(datalist, title="Example", + comment="This is just an example.", + apply=apply_test) + + _app.exec() + + # --------- datagroup example + datagroup = create_datagroup_example() + fedit(datagroup, "Global title", + apply=apply_test) + _app.exec() + + # --------- datagroup inside a datagroup example + datalist = create_datalist_example() + datagroup = create_datagroup_example() + fedit(((datagroup, "Title 1", "Tab 1 comment"), + (datalist, "Title 2", "Tab 2 comment"), + (datalist, "Title 3", "Tab 3 comment")), + "Global title", + apply=apply_test) + _app.exec() diff --git a/lib/python3.12/site-packages/matplotlib/backends/qt_editor/figureoptions.py b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/figureoptions.py new file mode 100644 index 0000000000000000000000000000000000000000..9d31fa9ced2ca24961597eddcc5e3e0f36f8fad8 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/qt_editor/figureoptions.py @@ -0,0 +1,271 @@ +# Copyright © 2009 Pierre Raybaut +# Licensed under the terms of the MIT License +# see the Matplotlib licenses directory for a copy of the license + + +"""Module that provides a GUI-based editor for Matplotlib's figure options.""" + +from itertools import chain +from matplotlib import cbook, cm, colors as mcolors, markers, image as mimage +from matplotlib.backends.qt_compat import QtGui +from matplotlib.backends.qt_editor import _formlayout +from matplotlib.dates import DateConverter, num2date + +LINESTYLES = {'-': 'Solid', + '--': 'Dashed', + '-.': 'DashDot', + ':': 'Dotted', + 'None': 'None', + } + +DRAWSTYLES = { + 'default': 'Default', + 'steps-pre': 'Steps (Pre)', 'steps': 'Steps (Pre)', + 'steps-mid': 'Steps (Mid)', + 'steps-post': 'Steps (Post)'} + +MARKERS = markers.MarkerStyle.markers + + +def figure_edit(axes, parent=None): + """Edit matplotlib figure options""" + sep = (None, None) # separator + + # Get / General + def convert_limits(lim, converter): + """Convert axis limits for correct input editors.""" + if isinstance(converter, DateConverter): + return map(num2date, lim) + # Cast to builtin floats as they have nicer reprs. + return map(float, lim) + + axis_map = axes._axis_map + axis_limits = { + name: tuple(convert_limits( + getattr(axes, f'get_{name}lim')(), axis.get_converter() + )) + for name, axis in axis_map.items() + } + general = [ + ('Title', axes.get_title()), + sep, + *chain.from_iterable([ + ( + (None, f"{name.title()}-Axis"), + ('Min', axis_limits[name][0]), + ('Max', axis_limits[name][1]), + ('Label', axis.label.get_text()), + ('Scale', [axis.get_scale(), + 'linear', 'log', 'symlog', 'logit']), + sep, + ) + for name, axis in axis_map.items() + ]), + ('(Re-)Generate automatic legend', False), + ] + + # Save the converter and unit data + axis_converter = { + name: axis.get_converter() + for name, axis in axis_map.items() + } + axis_units = { + name: axis.get_units() + for name, axis in axis_map.items() + } + + # Get / Curves + labeled_lines = [] + for line in axes.get_lines(): + label = line.get_label() + if label == '_nolegend_': + continue + labeled_lines.append((label, line)) + curves = [] + + def prepare_data(d, init): + """ + Prepare entry for FormLayout. + + *d* is a mapping of shorthands to style names (a single style may + have multiple shorthands, in particular the shorthands `None`, + `"None"`, `"none"` and `""` are synonyms); *init* is one shorthand + of the initial style. + + This function returns an list suitable for initializing a + FormLayout combobox, namely `[initial_name, (shorthand, + style_name), (shorthand, style_name), ...]`. + """ + if init not in d: + d = {**d, init: str(init)} + # Drop duplicate shorthands from dict (by overwriting them during + # the dict comprehension). + name2short = {name: short for short, name in d.items()} + # Convert back to {shorthand: name}. + short2name = {short: name for name, short in name2short.items()} + # Find the kept shorthand for the style specified by init. + canonical_init = name2short[d[init]] + # Sort by representation and prepend the initial value. + return ([canonical_init] + + sorted(short2name.items(), + key=lambda short_and_name: short_and_name[1])) + + for label, line in labeled_lines: + color = mcolors.to_hex( + mcolors.to_rgba(line.get_color(), line.get_alpha()), + keep_alpha=True) + ec = mcolors.to_hex( + mcolors.to_rgba(line.get_markeredgecolor(), line.get_alpha()), + keep_alpha=True) + fc = mcolors.to_hex( + mcolors.to_rgba(line.get_markerfacecolor(), line.get_alpha()), + keep_alpha=True) + curvedata = [ + ('Label', label), + sep, + (None, 'Line'), + ('Line style', prepare_data(LINESTYLES, line.get_linestyle())), + ('Draw style', prepare_data(DRAWSTYLES, line.get_drawstyle())), + ('Width', line.get_linewidth()), + ('Color (RGBA)', color), + sep, + (None, 'Marker'), + ('Style', prepare_data(MARKERS, line.get_marker())), + ('Size', line.get_markersize()), + ('Face color (RGBA)', fc), + ('Edge color (RGBA)', ec)] + curves.append([curvedata, label, ""]) + # Is there a curve displayed? + has_curve = bool(curves) + + # Get ScalarMappables. + labeled_mappables = [] + for mappable in [*axes.images, *axes.collections]: + label = mappable.get_label() + if label == '_nolegend_' or mappable.get_array() is None: + continue + labeled_mappables.append((label, mappable)) + mappables = [] + cmaps = [(cmap, name) for name, cmap in sorted(cm._colormaps.items())] + for label, mappable in labeled_mappables: + cmap = mappable.get_cmap() + if cmap.name not in cm._colormaps: + cmaps = [(cmap, cmap.name), *cmaps] + low, high = mappable.get_clim() + mappabledata = [ + ('Label', label), + ('Colormap', [cmap.name] + cmaps), + ('Min. value', low), + ('Max. value', high), + ] + if hasattr(mappable, "get_interpolation"): # Images. + interpolations = [ + (name, name) for name in sorted(mimage.interpolations_names)] + mappabledata.append(( + 'Interpolation', + [mappable.get_interpolation(), *interpolations])) + + interpolation_stages = ['data', 'rgba', 'auto'] + mappabledata.append(( + 'Interpolation stage', + [mappable.get_interpolation_stage(), *interpolation_stages])) + + mappables.append([mappabledata, label, ""]) + # Is there a scalarmappable displayed? + has_sm = bool(mappables) + + datalist = [(general, "Axes", "")] + if curves: + datalist.append((curves, "Curves", "")) + if mappables: + datalist.append((mappables, "Images, etc.", "")) + + def apply_callback(data): + """A callback to apply changes.""" + orig_limits = { + name: getattr(axes, f"get_{name}lim")() + for name in axis_map + } + + general = data.pop(0) + curves = data.pop(0) if has_curve else [] + mappables = data.pop(0) if has_sm else [] + if data: + raise ValueError("Unexpected field") + + title = general.pop(0) + axes.set_title(title) + generate_legend = general.pop() + + for i, (name, axis) in enumerate(axis_map.items()): + axis_min = general[4*i] + axis_max = general[4*i + 1] + axis_label = general[4*i + 2] + axis_scale = general[4*i + 3] + if axis.get_scale() != axis_scale: + getattr(axes, f"set_{name}scale")(axis_scale) + + axis._set_lim(axis_min, axis_max, auto=False) + axis.set_label_text(axis_label) + + # Restore the unit data + axis._set_converter(axis_converter[name]) + axis.set_units(axis_units[name]) + + # Set / Curves + for index, curve in enumerate(curves): + line = labeled_lines[index][1] + (label, linestyle, drawstyle, linewidth, color, marker, markersize, + markerfacecolor, markeredgecolor) = curve + line.set_label(label) + line.set_linestyle(linestyle) + line.set_drawstyle(drawstyle) + line.set_linewidth(linewidth) + rgba = mcolors.to_rgba(color) + line.set_alpha(None) + line.set_color(rgba) + if marker != 'none': + line.set_marker(marker) + line.set_markersize(markersize) + line.set_markerfacecolor(markerfacecolor) + line.set_markeredgecolor(markeredgecolor) + + # Set ScalarMappables. + for index, mappable_settings in enumerate(mappables): + mappable = labeled_mappables[index][1] + if len(mappable_settings) == 6: + label, cmap, low, high, interpolation, interpolation_stage = \ + mappable_settings + mappable.set_interpolation(interpolation) + mappable.set_interpolation_stage(interpolation_stage) + elif len(mappable_settings) == 4: + label, cmap, low, high = mappable_settings + mappable.set_label(label) + mappable.set_cmap(cmap) + mappable.set_clim(*sorted([low, high])) + + # re-generate legend, if checkbox is checked + if generate_legend: + draggable = None + ncols = 1 + if axes.legend_ is not None: + old_legend = axes.get_legend() + draggable = old_legend._draggable is not None + ncols = old_legend._ncols + new_legend = axes.legend(ncols=ncols) + if new_legend: + new_legend.set_draggable(draggable) + + # Redraw + figure = axes.get_figure() + figure.canvas.draw() + for name in axis_map: + if getattr(axes, f"get_{name}lim")() != orig_limits[name]: + figure.canvas.toolbar.push_current() + break + + _formlayout.fedit( + datalist, title="Figure options", parent=parent, + icon=QtGui.QIcon( + str(cbook._get_data_path('images', 'qt4_editor_options.svg'))), + apply=apply_callback) diff --git a/lib/python3.12/site-packages/matplotlib/backends/registry.py b/lib/python3.12/site-packages/matplotlib/backends/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..7f5a53f917a4c349a8bd12c0b38455e11b4dc638 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/registry.py @@ -0,0 +1,414 @@ +from enum import Enum +import importlib + + +class BackendFilter(Enum): + """ + Filter used with :meth:`~matplotlib.backends.registry.BackendRegistry.list_builtin` + + .. versionadded:: 3.9 + """ + INTERACTIVE = 0 + NON_INTERACTIVE = 1 + + +class BackendRegistry: + """ + Registry of backends available within Matplotlib. + + This is the single source of truth for available backends. + + All use of ``BackendRegistry`` should be via the singleton instance + ``backend_registry`` which can be imported from ``matplotlib.backends``. + + Each backend has a name, a module name containing the backend code, and an + optional GUI framework that must be running if the backend is interactive. + There are three sources of backends: built-in (source code is within the + Matplotlib repository), explicit ``module://some.backend`` syntax (backend is + obtained by loading the module), or via an entry point (self-registering + backend in an external package). + + .. versionadded:: 3.9 + """ + # Mapping of built-in backend name to GUI framework, or "headless" for no + # GUI framework. Built-in backends are those which are included in the + # Matplotlib repo. A backend with name 'name' is located in the module + # f"matplotlib.backends.backend_{name.lower()}" + _BUILTIN_BACKEND_TO_GUI_FRAMEWORK = { + "gtk3agg": "gtk3", + "gtk3cairo": "gtk3", + "gtk4agg": "gtk4", + "gtk4cairo": "gtk4", + "macosx": "macosx", + "nbagg": "nbagg", + "notebook": "nbagg", + "qtagg": "qt", + "qtcairo": "qt", + "qt5agg": "qt5", + "qt5cairo": "qt5", + "tkagg": "tk", + "tkcairo": "tk", + "webagg": "webagg", + "wx": "wx", + "wxagg": "wx", + "wxcairo": "wx", + "agg": "headless", + "cairo": "headless", + "pdf": "headless", + "pgf": "headless", + "ps": "headless", + "svg": "headless", + "template": "headless", + } + + # Reverse mapping of gui framework to preferred built-in backend. + _GUI_FRAMEWORK_TO_BACKEND = { + "gtk3": "gtk3agg", + "gtk4": "gtk4agg", + "headless": "agg", + "macosx": "macosx", + "qt": "qtagg", + "qt5": "qt5agg", + "qt6": "qtagg", + "tk": "tkagg", + "wx": "wxagg", + } + + def __init__(self): + # Only load entry points when first needed. + self._loaded_entry_points = False + + # Mapping of non-built-in backend to GUI framework, added dynamically from + # entry points and from matplotlib.use("module://some.backend") format. + # New entries have an "unknown" GUI framework that is determined when first + # needed by calling _get_gui_framework_by_loading. + self._backend_to_gui_framework = {} + + # Mapping of backend name to module name, where different from + # f"matplotlib.backends.backend_{backend_name.lower()}". These are either + # hardcoded for backward compatibility, or loaded from entry points or + # "module://some.backend" syntax. + self._name_to_module = { + "notebook": "nbagg", + } + + def _backend_module_name(self, backend): + if backend.startswith("module://"): + return backend[9:] + + # Return name of module containing the specified backend. + # Does not check if the backend is valid, use is_valid_backend for that. + backend = backend.lower() + + # Check if have specific name to module mapping. + backend = self._name_to_module.get(backend, backend) + + return (backend[9:] if backend.startswith("module://") + else f"matplotlib.backends.backend_{backend}") + + def _clear(self): + # Clear all dynamically-added data, used for testing only. + self.__init__() + + def _ensure_entry_points_loaded(self): + # Load entry points, if they have not already been loaded. + if not self._loaded_entry_points: + entries = self._read_entry_points() + self._validate_and_store_entry_points(entries) + self._loaded_entry_points = True + + def _get_gui_framework_by_loading(self, backend): + # Determine GUI framework for a backend by loading its module and reading the + # FigureCanvas.required_interactive_framework attribute. + # Returns "headless" if there is no GUI framework. + module = self.load_backend_module(backend) + canvas_class = module.FigureCanvas + return canvas_class.required_interactive_framework or "headless" + + def _read_entry_points(self): + # Read entry points of modules that self-advertise as Matplotlib backends. + # Expects entry points like this one from matplotlib-inline (in pyproject.toml + # format): + # [project.entry-points."matplotlib.backend"] + # inline = "matplotlib_inline.backend_inline" + import importlib.metadata as im + + entry_points = im.entry_points(group="matplotlib.backend") + entries = [(entry.name, entry.value) for entry in entry_points] + + # For backward compatibility, if matplotlib-inline and/or ipympl are installed + # but too old to include entry points, create them. Do not import ipympl + # directly as this calls matplotlib.use() whilst in this function. + def backward_compatible_entry_points( + entries, module_name, threshold_version, names, target): + from matplotlib import _parse_to_version_info + try: + module_version = im.version(module_name) + if _parse_to_version_info(module_version) < threshold_version: + for name in names: + entries.append((name, target)) + except im.PackageNotFoundError: + pass + + names = [entry[0] for entry in entries] + if "inline" not in names: + backward_compatible_entry_points( + entries, "matplotlib_inline", (0, 1, 7), ["inline"], + "matplotlib_inline.backend_inline") + if "ipympl" not in names: + backward_compatible_entry_points( + entries, "ipympl", (0, 9, 4), ["ipympl", "widget"], + "ipympl.backend_nbagg") + + return entries + + def _validate_and_store_entry_points(self, entries): + # Validate and store entry points so that they can be used via matplotlib.use() + # in the normal manner. Entry point names cannot be of module:// format, cannot + # shadow a built-in backend name, and there cannot be multiple entry points + # with the same name but different modules. Multiple entry points with the same + # name and value are permitted (it can sometimes happen outside of our control, + # see https://github.com/matplotlib/matplotlib/issues/28367). + for name, module in set(entries): + name = name.lower() + if name.startswith("module://"): + raise RuntimeError( + f"Entry point name '{name}' cannot start with 'module://'") + if name in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK: + raise RuntimeError(f"Entry point name '{name}' is a built-in backend") + if name in self._backend_to_gui_framework: + raise RuntimeError(f"Entry point name '{name}' duplicated") + + self._name_to_module[name] = "module://" + module + # Do not yet know backend GUI framework, determine it only when necessary. + self._backend_to_gui_framework[name] = "unknown" + + def backend_for_gui_framework(self, framework): + """ + Return the name of the backend corresponding to the specified GUI framework. + + Parameters + ---------- + framework : str + GUI framework such as "qt". + + Returns + ------- + str or None + Backend name or None if GUI framework not recognised. + """ + return self._GUI_FRAMEWORK_TO_BACKEND.get(framework.lower()) + + def is_valid_backend(self, backend): + """ + Return True if the backend name is valid, False otherwise. + + A backend name is valid if it is one of the built-in backends or has been + dynamically added via an entry point. Those beginning with ``module://`` are + always considered valid and are added to the current list of all backends + within this function. + + Even if a name is valid, it may not be importable or usable. This can only be + determined by loading and using the backend module. + + Parameters + ---------- + backend : str + Name of backend. + + Returns + ------- + bool + True if backend is valid, False otherwise. + """ + if not backend.startswith("module://"): + backend = backend.lower() + + # For backward compatibility, convert ipympl and matplotlib-inline long + # module:// names to their shortened forms. + backwards_compat = { + "module://ipympl.backend_nbagg": "widget", + "module://matplotlib_inline.backend_inline": "inline", + } + backend = backwards_compat.get(backend, backend) + + if (backend in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK or + backend in self._backend_to_gui_framework): + return True + + if backend.startswith("module://"): + self._backend_to_gui_framework[backend] = "unknown" + return True + + # Only load entry points if really need to and not already done so. + self._ensure_entry_points_loaded() + if backend in self._backend_to_gui_framework: + return True + + return False + + def list_all(self): + """ + Return list of all known backends. + + These include built-in backends and those obtained at runtime either from entry + points or explicit ``module://some.backend`` syntax. + + Entry points will be loaded if they haven't been already. + + Returns + ------- + list of str + Backend names. + """ + self._ensure_entry_points_loaded() + return [*self.list_builtin(), *self._backend_to_gui_framework] + + def list_builtin(self, filter_=None): + """ + Return list of backends that are built into Matplotlib. + + Parameters + ---------- + filter_ : `~.BackendFilter`, optional + Filter to apply to returned backends. For example, to return only + non-interactive backends use `.BackendFilter.NON_INTERACTIVE`. + + Returns + ------- + list of str + Backend names. + """ + if filter_ == BackendFilter.INTERACTIVE: + return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items() + if v != "headless"] + elif filter_ == BackendFilter.NON_INTERACTIVE: + return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items() + if v == "headless"] + + return [*self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK] + + def list_gui_frameworks(self): + """ + Return list of GUI frameworks used by Matplotlib backends. + + Returns + ------- + list of str + GUI framework names. + """ + return [k for k in self._GUI_FRAMEWORK_TO_BACKEND if k != "headless"] + + def load_backend_module(self, backend): + """ + Load and return the module containing the specified backend. + + Parameters + ---------- + backend : str + Name of backend to load. + + Returns + ------- + Module + Module containing backend. + """ + module_name = self._backend_module_name(backend) + return importlib.import_module(module_name) + + def resolve_backend(self, backend): + """ + Return the backend and GUI framework for the specified backend name. + + If the GUI framework is not yet known then it will be determined by loading the + backend module and checking the ``FigureCanvas.required_interactive_framework`` + attribute. + + This function only loads entry points if they have not already been loaded and + the backend is not built-in and not of ``module://some.backend`` format. + + Parameters + ---------- + backend : str or None + Name of backend, or None to use the default backend. + + Returns + ------- + backend : str + The backend name. + framework : str or None + The GUI framework, which will be None for a backend that is non-interactive. + """ + if isinstance(backend, str): + if not backend.startswith("module://"): + backend = backend.lower() + else: # Might be _auto_backend_sentinel or None + # Use whatever is already running... + from matplotlib import get_backend + backend = get_backend() + + # Is backend already known (built-in or dynamically loaded)? + gui = (self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.get(backend) or + self._backend_to_gui_framework.get(backend)) + + # Is backend "module://something"? + if gui is None and isinstance(backend, str) and backend.startswith("module://"): + gui = "unknown" + + # Is backend a possible entry point? + if gui is None and not self._loaded_entry_points: + self._ensure_entry_points_loaded() + gui = self._backend_to_gui_framework.get(backend) + + # Backend known but not its gui framework. + if gui == "unknown": + gui = self._get_gui_framework_by_loading(backend) + self._backend_to_gui_framework[backend] = gui + + if gui is None: + raise RuntimeError(f"'{backend}' is not a recognised backend name") + + return backend, gui if gui != "headless" else None + + def resolve_gui_or_backend(self, gui_or_backend): + """ + Return the backend and GUI framework for the specified string that may be + either a GUI framework or a backend name, tested in that order. + + This is for use with the IPython %matplotlib magic command which may be a GUI + framework such as ``%matplotlib qt`` or a backend name such as + ``%matplotlib qtagg``. + + This function only loads entry points if they have not already been loaded and + the backend is not built-in and not of ``module://some.backend`` format. + + Parameters + ---------- + gui_or_backend : str or None + Name of GUI framework or backend, or None to use the default backend. + + Returns + ------- + backend : str + The backend name. + framework : str or None + The GUI framework, which will be None for a backend that is non-interactive. + """ + if not gui_or_backend.startswith("module://"): + gui_or_backend = gui_or_backend.lower() + + # First check if it is a gui loop name. + backend = self.backend_for_gui_framework(gui_or_backend) + if backend is not None: + return backend, gui_or_backend if gui_or_backend != "headless" else None + + # Then check if it is a backend name. + try: + return self.resolve_backend(gui_or_backend) + except Exception: # KeyError ? + raise RuntimeError( + f"'{gui_or_backend}' is not a recognised GUI loop or backend name") + + +# Singleton +backend_registry = BackendRegistry() diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/all_figures.html b/lib/python3.12/site-packages/matplotlib/backends/web_backend/all_figures.html new file mode 100644 index 0000000000000000000000000000000000000000..62f04b65c9bf6b72e7b5de2c25e46a1b88109c89 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/all_figures.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + MPL | WebAgg current figures + + + +
+ +
+ + + diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/boilerplate.css b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/boilerplate.css new file mode 100644 index 0000000000000000000000000000000000000000..2b1535f4495e6f2242a09f6c822a01e34389bd98 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/boilerplate.css @@ -0,0 +1,77 @@ +/** + * HTML5 ✰ Boilerplate + * + * style.css contains a reset, font normalization and some base styles. + * + * Credit is left where credit is due. + * Much inspiration was taken from these projects: + * - yui.yahooapis.com/2.8.1/build/base/base.css + * - camendesign.com/design/ + * - praegnanz.de/weblog/htmlcssjs-kickstart + */ + + +/** + * html5doctor.com Reset Stylesheet (Eric Meyer's Reset Reloaded + HTML5 baseline) + * v1.6.1 2010-09-17 | Authors: Eric Meyer & Richard Clark + * html5doctor.com/html-5-reset-stylesheet/ + */ + +html, body, div, span, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, +small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, figcaption, figure, +footer, header, hgroup, menu, nav, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +sup { vertical-align: super; } +sub { vertical-align: sub; } + +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +blockquote, q { quotes: none; } + +blockquote:before, blockquote:after, +q:before, q:after { content: ""; content: none; } + +ins { background-color: #ff9; color: #000; text-decoration: none; } + +mark { background-color: #ff9; color: #000; font-style: italic; font-weight: bold; } + +del { text-decoration: line-through; } + +abbr[title], dfn[title] { border-bottom: 1px dotted; cursor: help; } + +table { border-collapse: collapse; border-spacing: 0; } + +hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; } + +input, select { vertical-align: middle; } + + +/** + * Font normalization inspired by YUI Library's fonts.css: developer.yahoo.com/yui/ + */ + +body { font:13px/1.231 sans-serif; *font-size:small; } /* Hack retained to preserve specificity */ +select, input, textarea, button { font:99% sans-serif; } + +/* Normalize monospace sizing: + en.wikipedia.org/wiki/MediaWiki_talk:Common.css/Archive_11#Teletype_style_fix_for_Chrome */ +pre, code, kbd, samp { font-family: monospace, sans-serif; } + +em,i { font-style: italic; } +b,strong { font-weight: bold; } diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/fbm.css b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/fbm.css new file mode 100644 index 0000000000000000000000000000000000000000..ce35d99a5e64c2e42a368aa61cd41ddb563d9ddd --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/fbm.css @@ -0,0 +1,97 @@ + +/* Flexible box model classes */ +/* Taken from Alex Russell https://infrequently.org/2009/08/css-3-progress/ */ + +.hbox { + display: -webkit-box; + -webkit-box-orient: horizontal; + -webkit-box-align: stretch; + + display: -moz-box; + -moz-box-orient: horizontal; + -moz-box-align: stretch; + + display: box; + box-orient: horizontal; + box-align: stretch; +} + +.hbox > * { + -webkit-box-flex: 0; + -moz-box-flex: 0; + box-flex: 0; +} + +.vbox { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-box-align: stretch; + + display: -moz-box; + -moz-box-orient: vertical; + -moz-box-align: stretch; + + display: box; + box-orient: vertical; + box-align: stretch; +} + +.vbox > * { + -webkit-box-flex: 0; + -moz-box-flex: 0; + box-flex: 0; +} + +.reverse { + -webkit-box-direction: reverse; + -moz-box-direction: reverse; + box-direction: reverse; +} + +.box-flex0 { + -webkit-box-flex: 0; + -moz-box-flex: 0; + box-flex: 0; +} + +.box-flex1, .box-flex { + -webkit-box-flex: 1; + -moz-box-flex: 1; + box-flex: 1; +} + +.box-flex2 { + -webkit-box-flex: 2; + -moz-box-flex: 2; + box-flex: 2; +} + +.box-group1 { + -webkit-box-flex-group: 1; + -moz-box-flex-group: 1; + box-flex-group: 1; +} + +.box-group2 { + -webkit-box-flex-group: 2; + -moz-box-flex-group: 2; + box-flex-group: 2; +} + +.start { + -webkit-box-pack: start; + -moz-box-pack: start; + box-pack: start; +} + +.end { + -webkit-box-pack: end; + -moz-box-pack: end; + box-pack: end; +} + +.center { + -webkit-box-pack: center; + -moz-box-pack: center; + box-pack: center; +} diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/mpl.css b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/mpl.css new file mode 100644 index 0000000000000000000000000000000000000000..e55733d25ecfc6237aa8052c1fc2b7e3f356da23 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/mpl.css @@ -0,0 +1,84 @@ +/* General styling */ +.ui-helper-clearfix:before, +.ui-helper-clearfix:after { + content: ""; + display: table; + border-collapse: collapse; +} +.ui-helper-clearfix:after { + clear: both; +} + +/* Header */ +.ui-widget-header { + border: 1px solid #dddddd; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + background: #e9e9e9; + color: #333333; + font-weight: bold; +} + +/* Toolbar and items */ +.mpl-toolbar { + width: 100%; +} + +.mpl-toolbar div.mpl-button-group { + display: inline-block; +} + +.mpl-button-group + .mpl-button-group { + margin-left: 0.5em; +} + +.mpl-widget { + background-color: #fff; + border: 1px solid #ccc; + display: inline-block; + cursor: pointer; + color: #333; + padding: 6px; + vertical-align: middle; +} + +.mpl-widget:disabled, +.mpl-widget[disabled] { + background-color: #ddd; + border-color: #ddd !important; + cursor: not-allowed; +} + +.mpl-widget:disabled img, +.mpl-widget[disabled] img { + /* Convert black to grey */ + filter: contrast(0%); +} + +.mpl-widget.active img { + /* Convert black to tab:blue, approximately */ + filter: invert(34%) sepia(97%) saturate(468%) hue-rotate(162deg) brightness(96%) contrast(91%); +} + +button.mpl-widget:focus, +button.mpl-widget:hover { + background-color: #ddd; + border-color: #aaa; +} + +.mpl-button-group button.mpl-widget { + margin-left: -1px; +} +.mpl-button-group button.mpl-widget:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + margin-left: 0px; +} +.mpl-button-group button.mpl-widget:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +select.mpl-widget { + cursor: default; +} diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/page.css b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/page.css new file mode 100644 index 0000000000000000000000000000000000000000..ded0d92203790eaaab21feea83930611acec51f0 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/css/page.css @@ -0,0 +1,82 @@ +/** + * Primary styles + * + * Author: IPython Development Team + */ + + +body { + background-color: white; + /* This makes sure that the body covers the entire window and needs to + be in a different element than the display: box in wrapper below */ + position: absolute; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + overflow: visible; +} + + +div#header { + /* Initially hidden to prevent FLOUC */ + display: none; + position: relative; + height: 40px; + padding: 5px; + margin: 0px; + width: 100%; +} + +span#ipython_notebook { + position: absolute; + padding: 2px 2px 2px 5px; +} + +span#ipython_notebook img { + font-family: Verdana, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif; + height: 24px; + text-decoration:none; + display: inline; + color: black; +} + +#site { + width: 100%; + display: none; +} + +/* We set the fonts by hand here to override the values in the theme */ +.ui-widget { + font-family: "Lucinda Grande", "Lucinda Sans Unicode", Helvetica, Arial, Verdana, sans-serif; +} + +.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { + font-family: "Lucinda Grande", "Lucinda Sans Unicode", Helvetica, Arial, Verdana, sans-serif; +} + +/* Smaller buttons */ +.ui-button .ui-button-text { + padding: 0.2em 0.8em; + font-size: 77%; +} + +input.ui-button { + padding: 0.3em 0.9em; +} + +span#login_widget { + float: right; +} + +.border-box-sizing { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +#figure-div { + display: inline-block; + margin: 10px; + vertical-align: top; +} diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/ipython_inline_figure.html b/lib/python3.12/site-packages/matplotlib/backends/web_backend/ipython_inline_figure.html new file mode 100644 index 0000000000000000000000000000000000000000..b941d352a7d6ca1351b7fb9879386c2d391e9be7 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/ipython_inline_figure.html @@ -0,0 +1,34 @@ + + diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl.js b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl.js new file mode 100644 index 0000000000000000000000000000000000000000..2d1f383e9839508df2ec05201b8d7cf8dae767ba --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl.js @@ -0,0 +1,705 @@ +/* Put everything inside the global mpl namespace */ +/* global mpl */ +window.mpl = {}; + +mpl.get_websocket_type = function () { + if (typeof WebSocket !== 'undefined') { + return WebSocket; + } else if (typeof MozWebSocket !== 'undefined') { + return MozWebSocket; + } else { + alert( + 'Your browser does not have WebSocket support. ' + + 'Please try Chrome, Safari or Firefox ≥ 6. ' + + 'Firefox 4 and 5 are also supported but you ' + + 'have to enable WebSockets in about:config.' + ); + } +}; + +mpl.figure = function (figure_id, websocket, ondownload, parent_element) { + this.id = figure_id; + + this.ws = websocket; + + this.supports_binary = this.ws.binaryType !== undefined; + + if (!this.supports_binary) { + var warnings = document.getElementById('mpl-warnings'); + if (warnings) { + warnings.style.display = 'block'; + warnings.textContent = + 'This browser does not support binary websocket messages. ' + + 'Performance may be slow.'; + } + } + + this.imageObj = new Image(); + + this.context = undefined; + this.message = undefined; + this.canvas = undefined; + this.rubberband_canvas = undefined; + this.rubberband_context = undefined; + this.format_dropdown = undefined; + + this.image_mode = 'full'; + + this.root = document.createElement('div'); + this.root.setAttribute('style', 'display: inline-block'); + this._root_extra_style(this.root); + + parent_element.appendChild(this.root); + + this._init_header(this); + this._init_canvas(this); + this._init_toolbar(this); + + var fig = this; + + this.waiting = false; + + this.ws.onopen = function () { + fig.send_message('supports_binary', { value: fig.supports_binary }); + fig.send_message('send_image_mode', {}); + if (fig.ratio !== 1) { + fig.send_message('set_device_pixel_ratio', { + device_pixel_ratio: fig.ratio, + }); + } + fig.send_message('refresh', {}); + }; + + this.imageObj.onload = function () { + if (fig.image_mode === 'full') { + // Full images could contain transparency (where diff images + // almost always do), so we need to clear the canvas so that + // there is no ghosting. + fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height); + } + fig.context.drawImage(fig.imageObj, 0, 0); + }; + + this.imageObj.onunload = function () { + fig.ws.close(); + }; + + this.ws.onmessage = this._make_on_message_function(this); + + this.ondownload = ondownload; +}; + +mpl.figure.prototype._init_header = function () { + var titlebar = document.createElement('div'); + titlebar.classList = + 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix'; + var titletext = document.createElement('div'); + titletext.classList = 'ui-dialog-title'; + titletext.setAttribute( + 'style', + 'width: 100%; text-align: center; padding: 3px;' + ); + titlebar.appendChild(titletext); + this.root.appendChild(titlebar); + this.header = titletext; +}; + +mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {}; + +mpl.figure.prototype._root_extra_style = function (_canvas_div) {}; + +mpl.figure.prototype._init_canvas = function () { + var fig = this; + + var canvas_div = (this.canvas_div = document.createElement('div')); + canvas_div.setAttribute('tabindex', '0'); + canvas_div.setAttribute( + 'style', + 'border: 1px solid #ddd;' + + 'box-sizing: content-box;' + + 'clear: both;' + + 'min-height: 1px;' + + 'min-width: 1px;' + + 'outline: 0;' + + 'overflow: hidden;' + + 'position: relative;' + + 'resize: both;' + + 'z-index: 2;' + ); + + function on_keyboard_event_closure(name) { + return function (event) { + return fig.key_event(event, name); + }; + } + + canvas_div.addEventListener( + 'keydown', + on_keyboard_event_closure('key_press') + ); + canvas_div.addEventListener( + 'keyup', + on_keyboard_event_closure('key_release') + ); + + this._canvas_extra_style(canvas_div); + this.root.appendChild(canvas_div); + + var canvas = (this.canvas = document.createElement('canvas')); + canvas.classList.add('mpl-canvas'); + canvas.setAttribute( + 'style', + 'box-sizing: content-box;' + + 'pointer-events: none;' + + 'position: relative;' + + 'z-index: 0;' + ); + + this.context = canvas.getContext('2d'); + + var backingStore = + this.context.backingStorePixelRatio || + this.context.webkitBackingStorePixelRatio || + this.context.mozBackingStorePixelRatio || + this.context.msBackingStorePixelRatio || + this.context.oBackingStorePixelRatio || + this.context.backingStorePixelRatio || + 1; + + this.ratio = (window.devicePixelRatio || 1) / backingStore; + + var rubberband_canvas = (this.rubberband_canvas = document.createElement( + 'canvas' + )); + rubberband_canvas.setAttribute( + 'style', + 'box-sizing: content-box;' + + 'left: 0;' + + 'pointer-events: none;' + + 'position: absolute;' + + 'top: 0;' + + 'z-index: 1;' + ); + + // Apply a ponyfill if ResizeObserver is not implemented by browser. + if (this.ResizeObserver === undefined) { + if (window.ResizeObserver !== undefined) { + this.ResizeObserver = window.ResizeObserver; + } else { + var obs = _JSXTOOLS_RESIZE_OBSERVER({}); + this.ResizeObserver = obs.ResizeObserver; + } + } + + this.resizeObserverInstance = new this.ResizeObserver(function (entries) { + // There's no need to resize if the WebSocket is not connected: + // - If it is still connecting, then we will get an initial resize from + // Python once it connects. + // - If it has disconnected, then resizing will clear the canvas and + // never get anything back to refill it, so better to not resize and + // keep something visible. + if (fig.ws.readyState != 1) { + return; + } + var nentries = entries.length; + for (var i = 0; i < nentries; i++) { + var entry = entries[i]; + var width, height; + if (entry.contentBoxSize) { + if (entry.contentBoxSize instanceof Array) { + // Chrome 84 implements new version of spec. + width = entry.contentBoxSize[0].inlineSize; + height = entry.contentBoxSize[0].blockSize; + } else { + // Firefox implements old version of spec. + width = entry.contentBoxSize.inlineSize; + height = entry.contentBoxSize.blockSize; + } + } else { + // Chrome <84 implements even older version of spec. + width = entry.contentRect.width; + height = entry.contentRect.height; + } + + // Keep the size of the canvas and rubber band canvas in sync with + // the canvas container. + if (entry.devicePixelContentBoxSize) { + // Chrome 84 implements new version of spec. + canvas.setAttribute( + 'width', + entry.devicePixelContentBoxSize[0].inlineSize + ); + canvas.setAttribute( + 'height', + entry.devicePixelContentBoxSize[0].blockSize + ); + } else { + canvas.setAttribute('width', width * fig.ratio); + canvas.setAttribute('height', height * fig.ratio); + } + /* This rescales the canvas back to display pixels, so that it + * appears correct on HiDPI screens. */ + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + rubberband_canvas.setAttribute('width', width); + rubberband_canvas.setAttribute('height', height); + + // And update the size in Python. We ignore the initial 0/0 size + // that occurs as the element is placed into the DOM, which should + // otherwise not happen due to the minimum size styling. + if (width != 0 && height != 0) { + fig.request_resize(width, height); + } + } + }); + this.resizeObserverInstance.observe(canvas_div); + + function on_mouse_event_closure(name) { + /* User Agent sniffing is bad, but WebKit is busted: + * https://bugs.webkit.org/show_bug.cgi?id=144526 + * https://bugs.webkit.org/show_bug.cgi?id=181818 + * The worst that happens here is that they get an extra browser + * selection when dragging, if this check fails to catch them. + */ + var UA = navigator.userAgent; + var isWebKit = /AppleWebKit/.test(UA) && !/Chrome/.test(UA); + if(isWebKit) { + return function (event) { + /* This prevents the web browser from automatically changing to + * the text insertion cursor when the button is pressed. We + * want to control all of the cursor setting manually through + * the 'cursor' event from matplotlib */ + event.preventDefault() + return fig.mouse_event(event, name); + }; + } else { + return function (event) { + return fig.mouse_event(event, name); + }; + } + } + + canvas_div.addEventListener( + 'mousedown', + on_mouse_event_closure('button_press') + ); + canvas_div.addEventListener( + 'mouseup', + on_mouse_event_closure('button_release') + ); + canvas_div.addEventListener( + 'dblclick', + on_mouse_event_closure('dblclick') + ); + // Throttle sequential mouse events to 1 every 20ms. + canvas_div.addEventListener( + 'mousemove', + on_mouse_event_closure('motion_notify') + ); + + canvas_div.addEventListener( + 'mouseenter', + on_mouse_event_closure('figure_enter') + ); + canvas_div.addEventListener( + 'mouseleave', + on_mouse_event_closure('figure_leave') + ); + + canvas_div.addEventListener('wheel', function (event) { + if (event.deltaY < 0) { + event.step = 1; + } else { + event.step = -1; + } + on_mouse_event_closure('scroll')(event); + }); + + canvas_div.appendChild(canvas); + canvas_div.appendChild(rubberband_canvas); + + this.rubberband_context = rubberband_canvas.getContext('2d'); + this.rubberband_context.strokeStyle = '#000000'; + + this._resize_canvas = function (width, height, forward) { + if (forward) { + canvas_div.style.width = width + 'px'; + canvas_div.style.height = height + 'px'; + } + }; + + // Disable right mouse context menu. + canvas_div.addEventListener('contextmenu', function (_e) { + event.preventDefault(); + return false; + }); + + function set_focus() { + canvas.focus(); + canvas_div.focus(); + } + + window.setTimeout(set_focus, 100); +}; + +mpl.figure.prototype._init_toolbar = function () { + var fig = this; + + var toolbar = document.createElement('div'); + toolbar.classList = 'mpl-toolbar'; + this.root.appendChild(toolbar); + + function on_click_closure(name) { + return function (_event) { + return fig.toolbar_button_onclick(name); + }; + } + + function on_mouseover_closure(tooltip) { + return function (event) { + if (!event.currentTarget.disabled) { + return fig.toolbar_button_onmouseover(tooltip); + } + }; + } + + fig.buttons = {}; + var buttonGroup = document.createElement('div'); + buttonGroup.classList = 'mpl-button-group'; + for (var toolbar_ind in mpl.toolbar_items) { + var name = mpl.toolbar_items[toolbar_ind][0]; + var tooltip = mpl.toolbar_items[toolbar_ind][1]; + var image = mpl.toolbar_items[toolbar_ind][2]; + var method_name = mpl.toolbar_items[toolbar_ind][3]; + + if (!name) { + /* Instead of a spacer, we start a new button group. */ + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + buttonGroup = document.createElement('div'); + buttonGroup.classList = 'mpl-button-group'; + continue; + } + + var button = (fig.buttons[name] = document.createElement('button')); + button.classList = 'mpl-widget'; + button.setAttribute('role', 'button'); + button.setAttribute('aria-disabled', 'false'); + button.addEventListener('click', on_click_closure(method_name)); + button.addEventListener('mouseover', on_mouseover_closure(tooltip)); + + var icon_img = document.createElement('img'); + icon_img.src = '_images/' + image + '.png'; + icon_img.srcset = '_images/' + image + '_large.png 2x'; + icon_img.alt = tooltip; + button.appendChild(icon_img); + + buttonGroup.appendChild(button); + } + + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + + var fmt_picker = document.createElement('select'); + fmt_picker.classList = 'mpl-widget'; + toolbar.appendChild(fmt_picker); + this.format_dropdown = fmt_picker; + + for (var ind in mpl.extensions) { + var fmt = mpl.extensions[ind]; + var option = document.createElement('option'); + option.selected = fmt === mpl.default_extension; + option.innerHTML = fmt; + fmt_picker.appendChild(option); + } + + var status_bar = document.createElement('span'); + status_bar.classList = 'mpl-message'; + toolbar.appendChild(status_bar); + this.message = status_bar; +}; + +mpl.figure.prototype.request_resize = function (x_pixels, y_pixels) { + // Request matplotlib to resize the figure. Matplotlib will then trigger a resize in the client, + // which will in turn request a refresh of the image. + this.send_message('resize', { width: x_pixels, height: y_pixels }); +}; + +mpl.figure.prototype.send_message = function (type, properties) { + properties['type'] = type; + properties['figure_id'] = this.id; + this.ws.send(JSON.stringify(properties)); +}; + +mpl.figure.prototype.send_draw_message = function () { + if (!this.waiting) { + this.waiting = true; + this.ws.send(JSON.stringify({ type: 'draw', figure_id: this.id })); + } +}; + +mpl.figure.prototype.handle_save = function (fig, _msg) { + var format_dropdown = fig.format_dropdown; + var format = format_dropdown.options[format_dropdown.selectedIndex].value; + fig.ondownload(fig, format); +}; + +mpl.figure.prototype.handle_resize = function (fig, msg) { + var size = msg['size']; + if (size[0] !== fig.canvas.width || size[1] !== fig.canvas.height) { + fig._resize_canvas(size[0], size[1], msg['forward']); + fig.send_message('refresh', {}); + } +}; + +mpl.figure.prototype.handle_rubberband = function (fig, msg) { + var x0 = msg['x0'] / fig.ratio; + var y0 = (fig.canvas.height - msg['y0']) / fig.ratio; + var x1 = msg['x1'] / fig.ratio; + var y1 = (fig.canvas.height - msg['y1']) / fig.ratio; + x0 = Math.floor(x0) + 0.5; + y0 = Math.floor(y0) + 0.5; + x1 = Math.floor(x1) + 0.5; + y1 = Math.floor(y1) + 0.5; + var min_x = Math.min(x0, x1); + var min_y = Math.min(y0, y1); + var width = Math.abs(x1 - x0); + var height = Math.abs(y1 - y0); + + fig.rubberband_context.clearRect( + 0, + 0, + fig.canvas.width / fig.ratio, + fig.canvas.height / fig.ratio + ); + + fig.rubberband_context.strokeRect(min_x, min_y, width, height); +}; + +mpl.figure.prototype.handle_figure_label = function (fig, msg) { + // Updates the figure title. + fig.header.textContent = msg['label']; +}; + +mpl.figure.prototype.handle_cursor = function (fig, msg) { + fig.canvas_div.style.cursor = msg['cursor']; +}; + +mpl.figure.prototype.handle_message = function (fig, msg) { + fig.message.textContent = msg['message']; +}; + +mpl.figure.prototype.handle_draw = function (fig, _msg) { + // Request the server to send over a new figure. + fig.send_draw_message(); +}; + +mpl.figure.prototype.handle_image_mode = function (fig, msg) { + fig.image_mode = msg['mode']; +}; + +mpl.figure.prototype.handle_history_buttons = function (fig, msg) { + for (var key in msg) { + if (!(key in fig.buttons)) { + continue; + } + fig.buttons[key].disabled = !msg[key]; + fig.buttons[key].setAttribute('aria-disabled', !msg[key]); + } +}; + +mpl.figure.prototype.handle_navigate_mode = function (fig, msg) { + if (msg['mode'] === 'PAN') { + fig.buttons['Pan'].classList.add('active'); + fig.buttons['Zoom'].classList.remove('active'); + } else if (msg['mode'] === 'ZOOM') { + fig.buttons['Pan'].classList.remove('active'); + fig.buttons['Zoom'].classList.add('active'); + } else { + fig.buttons['Pan'].classList.remove('active'); + fig.buttons['Zoom'].classList.remove('active'); + } +}; + +mpl.figure.prototype.updated_canvas_event = function () { + // Called whenever the canvas gets updated. + this.send_message('ack', {}); +}; + +// A function to construct a web socket function for onmessage handling. +// Called in the figure constructor. +mpl.figure.prototype._make_on_message_function = function (fig) { + return function socket_on_message(evt) { + if (evt.data instanceof Blob) { + var img = evt.data; + if (img.type !== 'image/png') { + /* FIXME: We get "Resource interpreted as Image but + * transferred with MIME type text/plain:" errors on + * Chrome. But how to set the MIME type? It doesn't seem + * to be part of the websocket stream */ + img.type = 'image/png'; + } + + /* Free the memory for the previous frames */ + if (fig.imageObj.src) { + (window.URL || window.webkitURL).revokeObjectURL( + fig.imageObj.src + ); + } + + fig.imageObj.src = (window.URL || window.webkitURL).createObjectURL( + img + ); + fig.updated_canvas_event(); + fig.waiting = false; + return; + } else if ( + typeof evt.data === 'string' && + evt.data.slice(0, 21) === 'data:image/png;base64' + ) { + fig.imageObj.src = evt.data; + fig.updated_canvas_event(); + fig.waiting = false; + return; + } + + var msg = JSON.parse(evt.data); + var msg_type = msg['type']; + + // Call the "handle_{type}" callback, which takes + // the figure and JSON message as its only arguments. + try { + var callback = fig['handle_' + msg_type]; + } catch (e) { + console.log( + "No handler for the '" + msg_type + "' message type: ", + msg + ); + return; + } + + if (callback) { + try { + // console.log("Handling '" + msg_type + "' message: ", msg); + callback(fig, msg); + } catch (e) { + console.log( + "Exception inside the 'handler_" + msg_type + "' callback:", + e, + e.stack, + msg + ); + } + } + }; +}; + +function getModifiers(event) { + var mods = []; + if (event.ctrlKey) { + mods.push('ctrl'); + } + if (event.altKey) { + mods.push('alt'); + } + if (event.shiftKey) { + mods.push('shift'); + } + if (event.metaKey) { + mods.push('meta'); + } + return mods; +} + +/* + * return a copy of an object with only non-object keys + * we need this to avoid circular references + * https://stackoverflow.com/a/24161582/3208463 + */ +function simpleKeys(original) { + return Object.keys(original).reduce(function (obj, key) { + if (typeof original[key] !== 'object') { + obj[key] = original[key]; + } + return obj; + }, {}); +} + +mpl.figure.prototype.mouse_event = function (event, name) { + if (name === 'button_press') { + this.canvas.focus(); + this.canvas_div.focus(); + } + + // from https://stackoverflow.com/q/1114465 + var boundingRect = this.canvas.getBoundingClientRect(); + var x = (event.clientX - boundingRect.left) * this.ratio; + var y = (event.clientY - boundingRect.top) * this.ratio; + + this.send_message(name, { + x: x, + y: y, + button: event.button, + step: event.step, + buttons: event.buttons, + modifiers: getModifiers(event), + guiEvent: simpleKeys(event), + }); + + return false; +}; + +mpl.figure.prototype._key_event_extra = function (_event, _name) { + // Handle any extra behaviour associated with a key event +}; + +mpl.figure.prototype.key_event = function (event, name) { + // Prevent repeat events + if (name === 'key_press') { + if (event.key === this._key) { + return; + } else { + this._key = event.key; + } + } + if (name === 'key_release') { + this._key = null; + } + + var value = ''; + if (event.ctrlKey && event.key !== 'Control') { + value += 'ctrl+'; + } + else if (event.altKey && event.key !== 'Alt') { + value += 'alt+'; + } + else if (event.shiftKey && event.key !== 'Shift') { + value += 'shift+'; + } + + value += 'k' + event.key; + + this._key_event_extra(event, name); + + this.send_message(name, { key: value, guiEvent: simpleKeys(event) }); + return false; +}; + +mpl.figure.prototype.toolbar_button_onclick = function (name) { + if (name === 'download') { + this.handle_save(this, null); + } else { + this.send_message('toolbar_button', { name: name }); + } +}; + +mpl.figure.prototype.toolbar_button_onmouseover = function (tooltip) { + this.message.textContent = tooltip; +}; + +///////////////// REMAINING CONTENT GENERATED BY embed_js.py ///////////////// +// prettier-ignore +var _JSXTOOLS_RESIZE_OBSERVER=function(A){var t,i=new WeakMap,n=new WeakMap,a=new WeakMap,r=new WeakMap,o=new Set;function s(e){if(!(this instanceof s))throw new TypeError("Constructor requires 'new' operator");i.set(this,e)}function h(){throw new TypeError("Function is not a constructor")}function c(e,t,i,n){e=0 in arguments?Number(arguments[0]):0,t=1 in arguments?Number(arguments[1]):0,i=2 in arguments?Number(arguments[2]):0,n=3 in arguments?Number(arguments[3]):0,this.right=(this.x=this.left=e)+(this.width=i),this.bottom=(this.y=this.top=t)+(this.height=n),Object.freeze(this)}function d(){t=requestAnimationFrame(d);var s=new WeakMap,p=new Set;o.forEach((function(t){r.get(t).forEach((function(i){var r=t instanceof window.SVGElement,o=a.get(t),d=r?0:parseFloat(o.paddingTop),f=r?0:parseFloat(o.paddingRight),l=r?0:parseFloat(o.paddingBottom),u=r?0:parseFloat(o.paddingLeft),g=r?0:parseFloat(o.borderTopWidth),m=r?0:parseFloat(o.borderRightWidth),w=r?0:parseFloat(o.borderBottomWidth),b=u+f,F=d+l,v=(r?0:parseFloat(o.borderLeftWidth))+m,W=g+w,y=r?0:t.offsetHeight-W-t.clientHeight,E=r?0:t.offsetWidth-v-t.clientWidth,R=b+v,z=F+W,M=r?t.width:parseFloat(o.width)-R-E,O=r?t.height:parseFloat(o.height)-z-y;if(n.has(t)){var k=n.get(t);if(k[0]===M&&k[1]===O)return}n.set(t,[M,O]);var S=Object.create(h.prototype);S.target=t,S.contentRect=new c(u,d,M,O),s.has(i)||(s.set(i,[]),p.add(i)),s.get(i).push(S)}))})),p.forEach((function(e){i.get(e).call(e,s.get(e),e)}))}return s.prototype.observe=function(i){if(i instanceof window.Element){r.has(i)||(r.set(i,new Set),o.add(i),a.set(i,window.getComputedStyle(i)));var n=r.get(i);n.has(this)||n.add(this),cancelAnimationFrame(t),t=requestAnimationFrame(d)}},s.prototype.unobserve=function(i){if(i instanceof window.Element&&r.has(i)){var n=r.get(i);n.has(this)&&(n.delete(this),n.size||(r.delete(i),o.delete(i))),n.size||r.delete(i),o.size||cancelAnimationFrame(t)}},A.DOMRectReadOnly=c,A.ResizeObserver=s,A.ResizeObserverEntry=h,A}; // eslint-disable-line diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl_tornado.js b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl_tornado.js new file mode 100644 index 0000000000000000000000000000000000000000..b3cab8b7b906d95fcfe221bf3aaf04722004f7a6 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/mpl_tornado.js @@ -0,0 +1,8 @@ +/* This .js file contains functions for matplotlib's built-in + tornado-based server, that are not relevant when embedding WebAgg + in another web application. */ + +/* exported mpl_ondownload */ +function mpl_ondownload(figure, format) { + window.open(figure.id + '/download.' + format, '_blank'); +} diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/nbagg_mpl.js b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/nbagg_mpl.js new file mode 100644 index 0000000000000000000000000000000000000000..26d79ff4e1da11d3c362f748e75b22026cc8438a --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/js/nbagg_mpl.js @@ -0,0 +1,275 @@ +/* global mpl */ + +var comm_websocket_adapter = function (comm) { + // Create a "websocket"-like object which calls the given IPython comm + // object with the appropriate methods. Currently this is a non binary + // socket, so there is still some room for performance tuning. + var ws = {}; + + ws.binaryType = comm.kernel.ws.binaryType; + ws.readyState = comm.kernel.ws.readyState; + function updateReadyState(_event) { + if (comm.kernel.ws) { + ws.readyState = comm.kernel.ws.readyState; + } else { + ws.readyState = 3; // Closed state. + } + } + comm.kernel.ws.addEventListener('open', updateReadyState); + comm.kernel.ws.addEventListener('close', updateReadyState); + comm.kernel.ws.addEventListener('error', updateReadyState); + + ws.close = function () { + comm.close(); + }; + ws.send = function (m) { + //console.log('sending', m); + comm.send(m); + }; + // Register the callback with on_msg. + comm.on_msg(function (msg) { + //console.log('receiving', msg['content']['data'], msg); + var data = msg['content']['data']; + if (data['blob'] !== undefined) { + data = { + data: new Blob(msg['buffers'], { type: data['blob'] }), + }; + } + // Pass the mpl event to the overridden (by mpl) onmessage function. + ws.onmessage(data); + }); + return ws; +}; + +mpl.mpl_figure_comm = function (comm, msg) { + // This is the function which gets called when the mpl process + // starts-up an IPython Comm through the "matplotlib" channel. + + var id = msg.content.data.id; + // Get hold of the div created by the display call when the Comm + // socket was opened in Python. + var element = document.getElementById(id); + var ws_proxy = comm_websocket_adapter(comm); + + function ondownload(figure, _format) { + window.open(figure.canvas.toDataURL()); + } + + var fig = new mpl.figure(id, ws_proxy, ondownload, element); + + // Call onopen now - mpl needs it, as it is assuming we've passed it a real + // web socket which is closed, not our websocket->open comm proxy. + ws_proxy.onopen(); + + fig.parent_element = element; + fig.cell_info = mpl.find_output_cell("
"); + if (!fig.cell_info) { + console.error('Failed to find cell for figure', id, fig); + return; + } + fig.cell_info[0].output_area.element.on( + 'cleared', + { fig: fig }, + fig._remove_fig_handler + ); +}; + +mpl.figure.prototype.handle_close = function (fig, msg) { + var width = fig.canvas.width / fig.ratio; + fig.cell_info[0].output_area.element.off( + 'cleared', + fig._remove_fig_handler + ); + fig.resizeObserverInstance.unobserve(fig.canvas_div); + + // Update the output cell to use the data from the current canvas. + fig.push_to_output(); + var dataURL = fig.canvas.toDataURL(); + // Re-enable the keyboard manager in IPython - without this line, in FF, + // the notebook keyboard shortcuts fail. + IPython.keyboard_manager.enable(); + fig.parent_element.innerHTML = + ''; + fig.close_ws(fig, msg); +}; + +mpl.figure.prototype.close_ws = function (fig, msg) { + fig.send_message('closing', msg); + // fig.ws.close() +}; + +mpl.figure.prototype.push_to_output = function (_remove_interactive) { + // Turn the data on the canvas into data in the output cell. + var width = this.canvas.width / this.ratio; + var dataURL = this.canvas.toDataURL(); + this.cell_info[1]['text/html'] = + ''; +}; + +mpl.figure.prototype.updated_canvas_event = function () { + // Tell IPython that the notebook contents must change. + IPython.notebook.set_dirty(true); + this.send_message('ack', {}); + var fig = this; + // Wait a second, then push the new image to the DOM so + // that it is saved nicely (might be nice to debounce this). + setTimeout(function () { + fig.push_to_output(); + }, 1000); +}; + +mpl.figure.prototype._init_toolbar = function () { + var fig = this; + + var toolbar = document.createElement('div'); + toolbar.classList = 'btn-toolbar'; + this.root.appendChild(toolbar); + + function on_click_closure(name) { + return function (_event) { + return fig.toolbar_button_onclick(name); + }; + } + + function on_mouseover_closure(tooltip) { + return function (event) { + if (!event.currentTarget.disabled) { + return fig.toolbar_button_onmouseover(tooltip); + } + }; + } + + fig.buttons = {}; + var buttonGroup = document.createElement('div'); + buttonGroup.classList = 'btn-group'; + var button; + for (var toolbar_ind in mpl.toolbar_items) { + var name = mpl.toolbar_items[toolbar_ind][0]; + var tooltip = mpl.toolbar_items[toolbar_ind][1]; + var image = mpl.toolbar_items[toolbar_ind][2]; + var method_name = mpl.toolbar_items[toolbar_ind][3]; + + if (!name) { + /* Instead of a spacer, we start a new button group. */ + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + buttonGroup = document.createElement('div'); + buttonGroup.classList = 'btn-group'; + continue; + } + + button = fig.buttons[name] = document.createElement('button'); + button.classList = 'btn btn-default'; + button.href = '#'; + button.title = name; + button.innerHTML = ''; + button.addEventListener('click', on_click_closure(method_name)); + button.addEventListener('mouseover', on_mouseover_closure(tooltip)); + buttonGroup.appendChild(button); + } + + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + + // Add the status bar. + var status_bar = document.createElement('span'); + status_bar.classList = 'mpl-message pull-right'; + toolbar.appendChild(status_bar); + this.message = status_bar; + + // Add the close button to the window. + var buttongrp = document.createElement('div'); + buttongrp.classList = 'btn-group inline pull-right'; + button = document.createElement('button'); + button.classList = 'btn btn-mini btn-primary'; + button.href = '#'; + button.title = 'Stop Interaction'; + button.innerHTML = ''; + button.addEventListener('click', function (_evt) { + fig.handle_close(fig, {}); + }); + button.addEventListener( + 'mouseover', + on_mouseover_closure('Stop Interaction') + ); + buttongrp.appendChild(button); + var titlebar = this.root.querySelector('.ui-dialog-titlebar'); + titlebar.insertBefore(buttongrp, titlebar.firstChild); +}; + +mpl.figure.prototype._remove_fig_handler = function (event) { + var fig = event.data.fig; + if (event.target !== this) { + // Ignore bubbled events from children. + return; + } + fig.close_ws(fig, {}); +}; + +mpl.figure.prototype._root_extra_style = function (el) { + el.style.boxSizing = 'content-box'; // override notebook setting of border-box. +}; + +mpl.figure.prototype._canvas_extra_style = function (el) { + // this is important to make the div 'focusable + el.setAttribute('tabindex', 0); + // reach out to IPython and tell the keyboard manager to turn it's self + // off when our div gets focus + + // location in version 3 + if (IPython.notebook.keyboard_manager) { + IPython.notebook.keyboard_manager.register_events(el); + } else { + // location in version 2 + IPython.keyboard_manager.register_events(el); + } +}; + +mpl.figure.prototype._key_event_extra = function (event, _name) { + // Check for shift+enter + if (event.shiftKey && event.which === 13) { + this.canvas_div.blur(); + // select the cell after this one + var index = IPython.notebook.find_cell_index(this.cell_info[0]); + IPython.notebook.select(index + 1); + } +}; + +mpl.figure.prototype.handle_save = function (fig, _msg) { + fig.ondownload(fig, null); +}; + +mpl.find_output_cell = function (html_output) { + // Return the cell and output element which can be found *uniquely* in the notebook. + // Note - this is a bit hacky, but it is done because the "notebook_saving.Notebook" + // IPython event is triggered only after the cells have been serialised, which for + // our purposes (turning an active figure into a static one), is too late. + var cells = IPython.notebook.get_cells(); + var ncells = cells.length; + for (var i = 0; i < ncells; i++) { + var cell = cells[i]; + if (cell.cell_type === 'code') { + for (var j = 0; j < cell.output_area.outputs.length; j++) { + var data = cell.output_area.outputs[j]; + if (data.data) { + // IPython >= 3 moved mimebundle to data attribute of output + data = data.data; + } + if (data['text/html'] === html_output) { + return [cell, data, j]; + } + } + } + } +}; + +// Register the function which deals with the matplotlib target/channel. +// The kernel may be null if the page has been refreshed. +if (IPython.notebook.kernel !== null) { + IPython.notebook.kernel.comm_manager.register_target( + 'matplotlib', + mpl.mpl_figure_comm + ); +} diff --git a/lib/python3.12/site-packages/matplotlib/backends/web_backend/single_figure.html b/lib/python3.12/site-packages/matplotlib/backends/web_backend/single_figure.html new file mode 100644 index 0000000000000000000000000000000000000000..ceaaab00669ff62e0724eff610dcb74adcff1674 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/backends/web_backend/single_figure.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + matplotlib + + + +
+
+ + diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/kpsewhich.lua b/lib/python3.12/site-packages/matplotlib/mpl-data/kpsewhich.lua new file mode 100644 index 0000000000000000000000000000000000000000..8e9172a45082567bee6802113aa40664d1bfc010 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/kpsewhich.lua @@ -0,0 +1,3 @@ +-- see dviread._LuatexKpsewhich +kpse.set_program_name("latex") +while true do print(kpse.lookup(io.read():gsub("\r", ""))); io.flush(); end diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/matplotlibrc b/lib/python3.12/site-packages/matplotlib/mpl-data/matplotlibrc new file mode 100644 index 0000000000000000000000000000000000000000..df4f4afadf96bfb30c5eb62f2b9301994cc395fd --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/matplotlibrc @@ -0,0 +1,813 @@ +#### MATPLOTLIBRC FORMAT + +## NOTE FOR END USERS: DO NOT EDIT THIS FILE! +## +## This is a sample Matplotlib configuration file - you can find a copy +## of it on your system in site-packages/matplotlib/mpl-data/matplotlibrc +## (relative to your Python installation location). +## DO NOT EDIT IT! +## +## If you wish to change your default style, copy this file to one of the +## following locations: +## Unix/Linux: +## $HOME/.config/matplotlib/matplotlibrc OR +## $XDG_CONFIG_HOME/matplotlib/matplotlibrc (if $XDG_CONFIG_HOME is set) +## Other platforms: +## $HOME/.matplotlib/matplotlibrc +## and edit that copy. +## +## See https://matplotlib.org/stable/users/explain/customizing.html#customizing-with-matplotlibrc-files +## for more details on the paths which are checked for the configuration file. +## +## Blank lines, or lines starting with a comment symbol, are ignored, as are +## trailing comments. Other lines must have the format: +## key: val # optional comment +## +## Formatting: Use PEP8-like style (as enforced in the rest of the codebase). +## All lines start with an additional '#', so that removing all leading '#'s +## yields a valid style file. +## +## Colors: for the color values below, you can either use +## - a Matplotlib color string, such as r, k, or b +## - an RGB tuple, such as (1.0, 0.5, 0.0) +## - a double-quoted hex string, such as "#ff00ff". +## The unquoted string ff00ff is also supported for backward +## compatibility, but is discouraged. +## - a scalar grayscale intensity such as 0.75 +## - a legal html color name, e.g., red, blue, darkslategray +## +## String values may optionally be enclosed in double quotes, which allows +## using the comment character # in the string. +## +## This file (and other style files) must be encoded as utf-8. +## +## Matplotlib configuration are currently divided into following parts: +## - BACKENDS +## - LINES +## - PATCHES +## - HATCHES +## - BOXPLOT +## - FONT +## - TEXT +## - LaTeX +## - AXES +## - DATES +## - TICKS +## - GRIDS +## - LEGEND +## - FIGURE +## - IMAGES +## - CONTOUR PLOTS +## - ERRORBAR PLOTS +## - HISTOGRAM PLOTS +## - SCATTER PLOTS +## - AGG RENDERING +## - PATHS +## - SAVING FIGURES +## - INTERACTIVE KEYMAPS +## - ANIMATION + +##### CONFIGURATION BEGINS HERE + + +## *************************************************************************** +## * BACKENDS * +## *************************************************************************** +## The default backend. If you omit this parameter, the first working +## backend from the following list is used: +## MacOSX QtAgg Gtk4Agg Gtk3Agg TkAgg WxAgg Agg +## Other choices include: +## QtCairo GTK4Cairo GTK3Cairo TkCairo WxCairo Cairo +## Qt5Agg Qt5Cairo Wx # deprecated. +## PS PDF SVG Template +## You can also deploy your own backend outside of Matplotlib by referring to +## the module name (which must be in the PYTHONPATH) as 'module://my_backend'. +##backend: Agg + +## The port to use for the web server in the WebAgg backend. +#webagg.port: 8988 + +## The address on which the WebAgg web server should be reachable +#webagg.address: 127.0.0.1 + +## If webagg.port is unavailable, a number of other random ports will +## be tried until one that is available is found. +#webagg.port_retries: 50 + +## When True, open the web browser to the plot that is shown +#webagg.open_in_browser: True + +## If you are running pyplot inside a GUI and your backend choice +## conflicts, we will automatically try to find a compatible one for +## you if backend_fallback is True +#backend_fallback: True + +#interactive: False +#figure.hooks: # list of dotted.module.name:dotted.callable.name +#toolbar: toolbar2 # {None, toolbar2, toolmanager} +#timezone: UTC # a pytz timezone string, e.g., US/Central or Europe/Paris + + +## *************************************************************************** +## * LINES * +## *************************************************************************** +## See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.lines +## for more information on line properties. +#lines.linewidth: 1.5 # line width in points +#lines.linestyle: - # solid line +#lines.color: C0 # has no affect on plot(); see axes.prop_cycle +#lines.marker: None # the default marker +#lines.markerfacecolor: auto # the default marker face color +#lines.markeredgecolor: auto # the default marker edge color +#lines.markeredgewidth: 1.0 # the line width around the marker symbol +#lines.markersize: 6 # marker size, in points +#lines.dash_joinstyle: round # {miter, round, bevel} +#lines.dash_capstyle: butt # {butt, round, projecting} +#lines.solid_joinstyle: round # {miter, round, bevel} +#lines.solid_capstyle: projecting # {butt, round, projecting} +#lines.antialiased: True # render lines in antialiased (no jaggies) + +## The three standard dash patterns. These are scaled by the linewidth. +#lines.dashed_pattern: 3.7, 1.6 +#lines.dashdot_pattern: 6.4, 1.6, 1, 1.6 +#lines.dotted_pattern: 1, 1.65 +#lines.scale_dashes: True + +#markers.fillstyle: full # {full, left, right, bottom, top, none} + +#pcolor.shading: auto +#pcolormesh.snap: True # Whether to snap the mesh to pixel boundaries. This is + # provided solely to allow old test images to remain + # unchanged. Set to False to obtain the previous behavior. + +## *************************************************************************** +## * PATCHES * +## *************************************************************************** +## Patches are graphical objects that fill 2D space, like polygons or circles. +## See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.patches +## for more information on patch properties. +#patch.linewidth: 1.0 # edge width in points. +#patch.facecolor: C0 +#patch.edgecolor: black # By default, Patches and Collections do not draw edges. + # This value is only used if facecolor is "none" + # (an Artist without facecolor and edgecolor would be + # invisible) or if patch.force_edgecolor is True. +#patch.force_edgecolor: False # By default, Patches and Collections do not draw edges. + # Set this to True to draw edges with patch.edgedcolor + # as the default edgecolor. + # This is mainly relevant for styles. +#patch.antialiased: True # render patches in antialiased (no jaggies) + + +## *************************************************************************** +## * HATCHES * +## *************************************************************************** +#hatch.color: black +#hatch.linewidth: 1.0 + + +## *************************************************************************** +## * BOXPLOT * +## *************************************************************************** +#boxplot.notch: False +#boxplot.vertical: True +#boxplot.whiskers: 1.5 +#boxplot.bootstrap: None +#boxplot.patchartist: False +#boxplot.showmeans: False +#boxplot.showcaps: True +#boxplot.showbox: True +#boxplot.showfliers: True +#boxplot.meanline: False + +#boxplot.flierprops.color: black +#boxplot.flierprops.marker: o +#boxplot.flierprops.markerfacecolor: none +#boxplot.flierprops.markeredgecolor: black +#boxplot.flierprops.markeredgewidth: 1.0 +#boxplot.flierprops.markersize: 6 +#boxplot.flierprops.linestyle: none +#boxplot.flierprops.linewidth: 1.0 + +#boxplot.boxprops.color: black +#boxplot.boxprops.linewidth: 1.0 +#boxplot.boxprops.linestyle: - + +#boxplot.whiskerprops.color: black +#boxplot.whiskerprops.linewidth: 1.0 +#boxplot.whiskerprops.linestyle: - + +#boxplot.capprops.color: black +#boxplot.capprops.linewidth: 1.0 +#boxplot.capprops.linestyle: - + +#boxplot.medianprops.color: C1 +#boxplot.medianprops.linewidth: 1.0 +#boxplot.medianprops.linestyle: - + +#boxplot.meanprops.color: C2 +#boxplot.meanprops.marker: ^ +#boxplot.meanprops.markerfacecolor: C2 +#boxplot.meanprops.markeredgecolor: C2 +#boxplot.meanprops.markersize: 6 +#boxplot.meanprops.linestyle: -- +#boxplot.meanprops.linewidth: 1.0 + + +## *************************************************************************** +## * FONT * +## *************************************************************************** +## The font properties used by `text.Text`. +## See https://matplotlib.org/stable/api/font_manager_api.html for more information +## on font properties. The 6 font properties used for font matching are +## given below with their default values. +## +## The font.family property can take either a single or multiple entries of any +## combination of concrete font names (not supported when rendering text with +## usetex) or the following five generic values: +## - 'serif' (e.g., Times), +## - 'sans-serif' (e.g., Helvetica), +## - 'cursive' (e.g., Zapf-Chancery), +## - 'fantasy' (e.g., Western), and +## - 'monospace' (e.g., Courier). +## Each of these values has a corresponding default list of font names +## (font.serif, etc.); the first available font in the list is used. Note that +## for font.serif, font.sans-serif, and font.monospace, the first element of +## the list (a DejaVu font) will always be used because DejaVu is shipped with +## Matplotlib and is thus guaranteed to be available; the other entries are +## left as examples of other possible values. +## +## The font.style property has three values: normal (or roman), italic +## or oblique. The oblique style will be used for italic, if it is not +## present. +## +## The font.variant property has two values: normal or small-caps. For +## TrueType fonts, which are scalable fonts, small-caps is equivalent +## to using a font size of 'smaller', or about 83 % of the current font +## size. +## +## The font.weight property has effectively 13 values: normal, bold, +## bolder, lighter, 100, 200, 300, ..., 900. Normal is the same as +## 400, and bold is 700. bolder and lighter are relative values with +## respect to the current weight. +## +## The font.stretch property has 11 values: ultra-condensed, +## extra-condensed, condensed, semi-condensed, normal, semi-expanded, +## expanded, extra-expanded, ultra-expanded, wider, and narrower. This +## property is not currently implemented. +## +## The font.size property is the default font size for text, given in points. +## 10 pt is the standard value. +## +## Note that font.size controls default text sizes. To configure +## special text sizes tick labels, axes, labels, title, etc., see the rc +## settings for axes and ticks. Special text sizes can be defined +## relative to font.size, using the following values: xx-small, x-small, +## small, medium, large, x-large, xx-large, larger, or smaller + +#font.family: sans-serif +#font.style: normal +#font.variant: normal +#font.weight: normal +#font.stretch: normal +#font.size: 10.0 + +#font.serif: DejaVu Serif, Bitstream Vera Serif, Computer Modern Roman, New Century Schoolbook, Century Schoolbook L, Utopia, ITC Bookman, Bookman, Nimbus Roman No9 L, Times New Roman, Times, Palatino, Charter, serif +#font.sans-serif: DejaVu Sans, Bitstream Vera Sans, Computer Modern Sans Serif, Lucida Grande, Verdana, Geneva, Lucid, Arial, Helvetica, Avant Garde, sans-serif +#font.cursive: Apple Chancery, Textile, Zapf Chancery, Sand, Script MT, Felipa, Comic Neue, Comic Sans MS, cursive +#font.fantasy: Chicago, Charcoal, Impact, Western, xkcd script, fantasy +#font.monospace: DejaVu Sans Mono, Bitstream Vera Sans Mono, Computer Modern Typewriter, Andale Mono, Nimbus Mono L, Courier New, Courier, Fixed, Terminal, monospace + + +## *************************************************************************** +## * TEXT * +## *************************************************************************** +## The text properties used by `text.Text`. +## See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.text +## for more information on text properties +#text.color: black + +## FreeType hinting flag ("foo" corresponds to FT_LOAD_FOO); may be one of the +## following (Proprietary Matplotlib-specific synonyms are given in parentheses, +## but their use is discouraged): +## - default: Use the font's native hinter if possible, else FreeType's auto-hinter. +## ("either" is a synonym). +## - no_autohint: Use the font's native hinter if possible, else don't hint. +## ("native" is a synonym.) +## - force_autohint: Use FreeType's auto-hinter. ("auto" is a synonym.) +## - no_hinting: Disable hinting. ("none" is a synonym.) +#text.hinting: force_autohint + +#text.hinting_factor: 8 # Specifies the amount of softness for hinting in the + # horizontal direction. A value of 1 will hint to full + # pixels. A value of 2 will hint to half pixels etc. +#text.kerning_factor: 0 # Specifies the scaling factor for kerning values. This + # is provided solely to allow old test images to remain + # unchanged. Set to 6 to obtain previous behavior. + # Values other than 0 or 6 have no defined meaning. +#text.antialiased: True # If True (default), the text will be antialiased. + # This only affects raster outputs. +#text.parse_math: True # Use mathtext if there is an even number of unescaped + # dollar signs. + + +## *************************************************************************** +## * LaTeX * +## *************************************************************************** +## For more information on LaTeX properties, see +## https://matplotlib.org/stable/users/explain/text/usetex.html +#text.usetex: False # use latex for all text handling. The following fonts + # are supported through the usual rc parameter settings: + # new century schoolbook, bookman, times, palatino, + # zapf chancery, charter, serif, sans-serif, helvetica, + # avant garde, courier, monospace, computer modern roman, + # computer modern sans serif, computer modern typewriter +#text.latex.preamble: # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURES + # AND IS THEREFORE UNSUPPORTED. PLEASE DO NOT ASK FOR HELP + # IF THIS FEATURE DOES NOT DO WHAT YOU EXPECT IT TO. + # text.latex.preamble is a single line of LaTeX code that + # will be passed on to the LaTeX system. It may contain + # any code that is valid for the LaTeX "preamble", i.e. + # between the "\documentclass" and "\begin{document}" + # statements. + # Note that it has to be put on a single line, which may + # become quite long. + # The following packages are always loaded with usetex, + # so beware of package collisions: + # geometry, inputenc, type1cm. + # PostScript (PSNFSS) font packages may also be + # loaded, depending on your font settings. + +## The following settings allow you to select the fonts in math mode. +#mathtext.fontset: dejavusans # Should be 'dejavusans' (default), + # 'dejavuserif', 'cm' (Computer Modern), 'stix', + # 'stixsans' or 'custom' +## "mathtext.fontset: custom" is defined by the mathtext.bf, .cal, .it, ... +## settings which map a TeX font name to a fontconfig font pattern. (These +## settings are not used for other font sets.) +#mathtext.bf: sans:bold +#mathtext.bfit: sans:italic:bold +#mathtext.cal: cursive +#mathtext.it: sans:italic +#mathtext.rm: sans +#mathtext.sf: sans +#mathtext.tt: monospace +#mathtext.fallback: cm # Select fallback font from ['cm' (Computer Modern), 'stix' + # 'stixsans'] when a symbol cannot be found in one of the + # custom math fonts. Select 'None' to not perform fallback + # and replace the missing character by a dummy symbol. +#mathtext.default: it # The default font to use for math. + # Can be any of the LaTeX font names, including + # the special name "regular" for the same font + # used in regular text. + + +## *************************************************************************** +## * AXES * +## *************************************************************************** +## Following are default face and edge colors, default tick sizes, +## default font sizes for tick labels, and so on. See +## https://matplotlib.org/stable/api/axes_api.html#module-matplotlib.axes +#axes.facecolor: white # axes background color +#axes.edgecolor: black # axes edge color +#axes.linewidth: 0.8 # edge line width +#axes.grid: False # display grid or not +#axes.grid.axis: both # which axis the grid should apply to +#axes.grid.which: major # grid lines at {major, minor, both} ticks +#axes.titlelocation: center # alignment of the title: {left, right, center} +#axes.titlesize: large # font size of the axes title +#axes.titleweight: normal # font weight of title +#axes.titlecolor: auto # color of the axes title, auto falls back to + # text.color as default value +#axes.titley: None # position title (axes relative units). None implies auto +#axes.titlepad: 6.0 # pad between axes and title in points +#axes.labelsize: medium # font size of the x and y labels +#axes.labelpad: 4.0 # space between label and axis +#axes.labelweight: normal # weight of the x and y labels +#axes.labelcolor: black +#axes.axisbelow: line # draw axis gridlines and ticks: + # - below patches (True) + # - above patches but below lines ('line') + # - above all (False) + +#axes.formatter.limits: -5, 6 # use scientific notation if log10 + # of the axis range is smaller than the + # first or larger than the second +#axes.formatter.use_locale: False # When True, format tick labels + # according to the user's locale. + # For example, use ',' as a decimal + # separator in the fr_FR locale. +#axes.formatter.use_mathtext: False # When True, use mathtext for scientific + # notation. +#axes.formatter.min_exponent: 0 # minimum exponent to format in scientific notation +#axes.formatter.useoffset: True # If True, the tick label formatter + # will default to labeling ticks relative + # to an offset when the data range is + # small compared to the minimum absolute + # value of the data. +#axes.formatter.offset_threshold: 4 # When useoffset is True, the offset + # will be used when it can remove + # at least this number of significant + # digits from tick labels. + +#axes.spines.left: True # display axis spines +#axes.spines.bottom: True +#axes.spines.top: True +#axes.spines.right: True + +#axes.unicode_minus: True # use Unicode for the minus symbol rather than hyphen. See + # https://en.wikipedia.org/wiki/Plus_and_minus_signs#Character_codes +#axes.prop_cycle: cycler('color', ['1f77b4', 'ff7f0e', '2ca02c', 'd62728', '9467bd', '8c564b', 'e377c2', '7f7f7f', 'bcbd22', '17becf']) + # color cycle for plot lines as list of string color specs: + # single letter, long name, or web-style hex + # As opposed to all other parameters in this file, the color + # values must be enclosed in quotes for this parameter, + # e.g. '1f77b4', instead of 1f77b4. + # See also https://matplotlib.org/stable/users/explain/artists/color_cycle.html + # for more details on prop_cycle usage. +#axes.xmargin: .05 # x margin. See `axes.Axes.margins` +#axes.ymargin: .05 # y margin. See `axes.Axes.margins` +#axes.zmargin: .05 # z margin. See `axes.Axes.margins` +#axes.autolimit_mode: data # If "data", use axes.xmargin and axes.ymargin as is. + # If "round_numbers", after application of margins, axis + # limits are further expanded to the nearest "round" number. +#polaraxes.grid: True # display grid on polar axes +#axes3d.grid: True # display grid on 3D axes +#axes3d.automargin: False # automatically add margin when manually setting 3D axis limits + +#axes3d.xaxis.panecolor: (0.95, 0.95, 0.95, 0.5) # background pane on 3D axes +#axes3d.yaxis.panecolor: (0.90, 0.90, 0.90, 0.5) # background pane on 3D axes +#axes3d.zaxis.panecolor: (0.925, 0.925, 0.925, 0.5) # background pane on 3D axes + +#axes3d.mouserotationstyle: arcball # {azel, trackball, sphere, arcball} + # See also https://matplotlib.org/stable/api/toolkits/mplot3d/view_angles.html#rotation-with-mouse +#axes3d.trackballsize: 0.667 # trackball diameter, in units of the Axes bbox +#axes3d.trackballborder: 0.2 # trackball border width, in units of the Axes bbox (only for 'sphere' and 'arcball' style) + +## *************************************************************************** +## * AXIS * +## *************************************************************************** +#xaxis.labellocation: center # alignment of the xaxis label: {left, right, center} +#yaxis.labellocation: center # alignment of the yaxis label: {bottom, top, center} + + +## *************************************************************************** +## * DATES * +## *************************************************************************** +## These control the default format strings used in AutoDateFormatter. +## Any valid format datetime format string can be used (see the python +## `datetime` for details). For example, by using: +## - '%x' will use the locale date representation +## - '%X' will use the locale time representation +## - '%c' will use the full locale datetime representation +## These values map to the scales: +## {'year': 365, 'month': 30, 'day': 1, 'hour': 1/24, 'minute': 1 / (24 * 60)} + +#date.autoformatter.year: %Y +#date.autoformatter.month: %Y-%m +#date.autoformatter.day: %Y-%m-%d +#date.autoformatter.hour: %m-%d %H +#date.autoformatter.minute: %d %H:%M +#date.autoformatter.second: %H:%M:%S +#date.autoformatter.microsecond: %M:%S.%f +## The reference date for Matplotlib's internal date representation +## See https://matplotlib.org/stable/gallery/ticks/date_precision_and_epochs.html +#date.epoch: 1970-01-01T00:00:00 +## 'auto', 'concise': +#date.converter: auto +## For auto converter whether to use interval_multiples: +#date.interval_multiples: True + +## *************************************************************************** +## * TICKS * +## *************************************************************************** +## See https://matplotlib.org/stable/api/axis_api.html#matplotlib.axis.Tick +#xtick.top: False # draw ticks on the top side +#xtick.bottom: True # draw ticks on the bottom side +#xtick.labeltop: False # draw label on the top +#xtick.labelbottom: True # draw label on the bottom +#xtick.major.size: 3.5 # major tick size in points +#xtick.minor.size: 2 # minor tick size in points +#xtick.major.width: 0.8 # major tick width in points +#xtick.minor.width: 0.6 # minor tick width in points +#xtick.major.pad: 3.5 # distance to major tick label in points +#xtick.minor.pad: 3.4 # distance to the minor tick label in points +#xtick.color: black # color of the ticks +#xtick.labelcolor: inherit # color of the tick labels or inherit from xtick.color +#xtick.labelsize: medium # font size of the tick labels +#xtick.direction: out # direction: {in, out, inout} +#xtick.minor.visible: False # visibility of minor ticks on x-axis +#xtick.major.top: True # draw x axis top major ticks +#xtick.major.bottom: True # draw x axis bottom major ticks +#xtick.minor.top: True # draw x axis top minor ticks +#xtick.minor.bottom: True # draw x axis bottom minor ticks +#xtick.minor.ndivs: auto # number of minor ticks between the major ticks on x-axis +#xtick.alignment: center # alignment of xticks + +#ytick.left: True # draw ticks on the left side +#ytick.right: False # draw ticks on the right side +#ytick.labelleft: True # draw tick labels on the left side +#ytick.labelright: False # draw tick labels on the right side +#ytick.major.size: 3.5 # major tick size in points +#ytick.minor.size: 2 # minor tick size in points +#ytick.major.width: 0.8 # major tick width in points +#ytick.minor.width: 0.6 # minor tick width in points +#ytick.major.pad: 3.5 # distance to major tick label in points +#ytick.minor.pad: 3.4 # distance to the minor tick label in points +#ytick.color: black # color of the ticks +#ytick.labelcolor: inherit # color of the tick labels or inherit from ytick.color +#ytick.labelsize: medium # font size of the tick labels +#ytick.direction: out # direction: {in, out, inout} +#ytick.minor.visible: False # visibility of minor ticks on y-axis +#ytick.major.left: True # draw y axis left major ticks +#ytick.major.right: True # draw y axis right major ticks +#ytick.minor.left: True # draw y axis left minor ticks +#ytick.minor.right: True # draw y axis right minor ticks +#ytick.minor.ndivs: auto # number of minor ticks between the major ticks on y-axis +#ytick.alignment: center_baseline # alignment of yticks + + +## *************************************************************************** +## * GRIDS * +## *************************************************************************** +#grid.color: "#b0b0b0" # grid color +#grid.linestyle: - # solid +#grid.linewidth: 0.8 # in points +#grid.alpha: 1.0 # transparency, between 0.0 and 1.0 + + +## *************************************************************************** +## * LEGEND * +## *************************************************************************** +#legend.loc: best +#legend.frameon: True # if True, draw the legend on a background patch +#legend.framealpha: 0.8 # legend patch transparency +#legend.facecolor: inherit # inherit from axes.facecolor; or color spec +#legend.edgecolor: 0.8 # background patch boundary color +#legend.fancybox: True # if True, use a rounded box for the + # legend background, else a rectangle +#legend.shadow: False # if True, give background a shadow effect +#legend.numpoints: 1 # the number of marker points in the legend line +#legend.scatterpoints: 1 # number of scatter points +#legend.markerscale: 1.0 # the relative size of legend markers vs. original +#legend.fontsize: medium +#legend.labelcolor: None +#legend.title_fontsize: None # None sets to the same as the default axes. + +## Dimensions as fraction of font size: +#legend.borderpad: 0.4 # border whitespace +#legend.labelspacing: 0.5 # the vertical space between the legend entries +#legend.handlelength: 2.0 # the length of the legend lines +#legend.handleheight: 0.7 # the height of the legend handle +#legend.handletextpad: 0.8 # the space between the legend line and legend text +#legend.borderaxespad: 0.5 # the border between the axes and legend edge +#legend.columnspacing: 2.0 # column separation + + +## *************************************************************************** +## * FIGURE * +## *************************************************************************** +## See https://matplotlib.org/stable/api/figure_api.html#matplotlib.figure.Figure +#figure.titlesize: large # size of the figure title (``Figure.suptitle()``) +#figure.titleweight: normal # weight of the figure title +#figure.labelsize: large # size of the figure label (``Figure.sup[x|y]label()``) +#figure.labelweight: normal # weight of the figure label +#figure.figsize: 6.4, 4.8 # figure size in inches +#figure.dpi: 100 # figure dots per inch +#figure.facecolor: white # figure face color +#figure.edgecolor: white # figure edge color +#figure.frameon: True # enable figure frame +#figure.max_open_warning: 20 # The maximum number of figures to open through + # the pyplot interface before emitting a warning. + # If less than one this feature is disabled. +#figure.raise_window : True # Raise the GUI window to front when show() is called. + +## The figure subplot parameters. All dimensions are a fraction of the figure width and height. +#figure.subplot.left: 0.125 # the left side of the subplots of the figure +#figure.subplot.right: 0.9 # the right side of the subplots of the figure +#figure.subplot.bottom: 0.11 # the bottom of the subplots of the figure +#figure.subplot.top: 0.88 # the top of the subplots of the figure +#figure.subplot.wspace: 0.2 # the amount of width reserved for space between subplots, + # expressed as a fraction of the average axis width +#figure.subplot.hspace: 0.2 # the amount of height reserved for space between subplots, + # expressed as a fraction of the average axis height + +## Figure layout +#figure.autolayout: False # When True, automatically adjust subplot + # parameters to make the plot fit the figure + # using `tight_layout` +#figure.constrained_layout.use: False # When True, automatically make plot + # elements fit on the figure. (Not + # compatible with `autolayout`, above). +## Padding (in inches) around axes; defaults to 3/72 inches, i.e. 3 points. +#figure.constrained_layout.h_pad: 0.04167 +#figure.constrained_layout.w_pad: 0.04167 +## Spacing between subplots, relative to the subplot sizes. Much smaller than for +## tight_layout (figure.subplot.hspace, figure.subplot.wspace) as constrained_layout +## already takes surrounding texts (titles, labels, # ticklabels) into account. +#figure.constrained_layout.hspace: 0.02 +#figure.constrained_layout.wspace: 0.02 + + +## *************************************************************************** +## * IMAGES * +## *************************************************************************** +#image.aspect: equal # {equal, auto} or a number +#image.interpolation: auto # see help(imshow) for options +#image.interpolation_stage: auto # see help(imshow) for options +#image.cmap: viridis # A colormap name (plasma, magma, etc.) +#image.lut: 256 # the size of the colormap lookup table +#image.origin: upper # {lower, upper} +#image.resample: True +#image.composite_image: True # When True, all the images on a set of axes are + # combined into a single composite image before + # saving a figure as a vector graphics file, + # such as a PDF. + + +## *************************************************************************** +## * CONTOUR PLOTS * +## *************************************************************************** +#contour.negative_linestyle: dashed # string or on-off ink sequence +#contour.corner_mask: True # {True, False} +#contour.linewidth: None # {float, None} Size of the contour line + # widths. If set to None, it falls back to + # `line.linewidth`. +#contour.algorithm: mpl2014 # {mpl2005, mpl2014, serial, threaded} + + +## *************************************************************************** +## * ERRORBAR PLOTS * +## *************************************************************************** +#errorbar.capsize: 0 # length of end cap on error bars in pixels + + +## *************************************************************************** +## * HISTOGRAM PLOTS * +## *************************************************************************** +#hist.bins: 10 # The default number of histogram bins or 'auto'. + + +## *************************************************************************** +## * SCATTER PLOTS * +## *************************************************************************** +#scatter.marker: o # The default marker type for scatter plots. +#scatter.edgecolors: face # The default edge colors for scatter plots. + + +## *************************************************************************** +## * AGG RENDERING * +## *************************************************************************** +## Warning: experimental, 2008/10/10 +#agg.path.chunksize: 0 # 0 to disable; values in the range + # 10000 to 100000 can improve speed slightly + # and prevent an Agg rendering failure + # when plotting very large data sets, + # especially if they are very gappy. + # It may cause minor artifacts, though. + # A value of 20000 is probably a good + # starting point. + + +## *************************************************************************** +## * PATHS * +## *************************************************************************** +#path.simplify: True # When True, simplify paths by removing "invisible" + # points to reduce file size and increase rendering + # speed +#path.simplify_threshold: 0.111111111111 # The threshold of similarity below + # which vertices will be removed in + # the simplification process. +#path.snap: True # When True, rectilinear axis-aligned paths will be snapped + # to the nearest pixel when certain criteria are met. + # When False, paths will never be snapped. +#path.sketch: None # May be None, or a tuple of the form: + # path.sketch: (scale, length, randomness) + # - *scale* is the amplitude of the wiggle + # perpendicular to the line (in pixels). + # - *length* is the length of the wiggle along the + # line (in pixels). + # - *randomness* is the factor by which the length is + # randomly scaled. +#path.effects: + + +## *************************************************************************** +## * SAVING FIGURES * +## *************************************************************************** +## The default savefig parameters can be different from the display parameters +## e.g., you may want a higher resolution, or to make the figure +## background white +#savefig.dpi: figure # figure dots per inch or 'figure' +#savefig.facecolor: auto # figure face color when saving +#savefig.edgecolor: auto # figure edge color when saving +#savefig.format: png # {png, ps, pdf, svg} +#savefig.bbox: standard # {tight, standard} + # 'tight' is incompatible with generating frames + # for animation +#savefig.pad_inches: 0.1 # padding to be used, when bbox is set to 'tight' +#savefig.directory: ~ # default directory in savefig dialog, gets updated after + # interactive saves, unless set to the empty string (i.e. + # the current directory); use '.' to start at the current + # directory but update after interactive saves +#savefig.transparent: False # whether figures are saved with a transparent + # background by default +#savefig.orientation: portrait # orientation of saved figure, for PostScript output only + +### macosx backend params +#macosx.window_mode : system # How to open new figures (system, tab, window) + # system uses the MacOS system preferences + +### tk backend params +#tk.window_focus: False # Maintain shell focus for TkAgg + +### ps backend params +#ps.papersize: letter # {figure, letter, legal, ledger, A0-A10, B0-B10} +#ps.useafm: False # use AFM fonts, results in small files +#ps.usedistiller: False # {ghostscript, xpdf, None} + # Experimental: may produce smaller files. + # xpdf intended for production of publication quality files, + # but requires ghostscript, xpdf and ps2eps +#ps.distiller.res: 6000 # dpi +#ps.fonttype: 3 # Output Type 3 (Type3) or Type 42 (TrueType) + +### PDF backend params +#pdf.compression: 6 # integer from 0 to 9 + # 0 disables compression (good for debugging) +#pdf.fonttype: 3 # Output Type 3 (Type3) or Type 42 (TrueType) +#pdf.use14corefonts: False +#pdf.inheritcolor: False + +### SVG backend params +#svg.image_inline: True # Write raster image data directly into the SVG file +#svg.fonttype: path # How to handle SVG fonts: + # path: Embed characters as paths -- supported + # by most SVG renderers + # None: Assume fonts are installed on the + # machine where the SVG will be viewed. +#svg.hashsalt: None # If not None, use this string as hash salt instead of uuid4 +#svg.id: None # If not None, use this string as the value for the `id` + # attribute in the top tag + +### pgf parameter +## See https://matplotlib.org/stable/tutorials/text/pgf.html for more information. +#pgf.rcfonts: True +#pgf.preamble: # See text.latex.preamble for documentation +#pgf.texsystem: xelatex + +### docstring params +#docstring.hardcopy: False # set this when you want to generate hardcopy docstring + + +## *************************************************************************** +## * INTERACTIVE KEYMAPS * +## *************************************************************************** +## Event keys to interact with figures/plots via keyboard. +## See https://matplotlib.org/stable/users/explain/interactive.html for more +## details on interactive navigation. Customize these settings according to +## your needs. Leave the field(s) empty if you don't need a key-map. (i.e., +## fullscreen : '') +#keymap.fullscreen: f, ctrl+f # toggling +#keymap.home: h, r, home # home or reset mnemonic +#keymap.back: left, c, backspace, MouseButton.BACK # forward / backward keys +#keymap.forward: right, v, MouseButton.FORWARD # for quick navigation +#keymap.pan: p # pan mnemonic +#keymap.zoom: o # zoom mnemonic +#keymap.save: s, ctrl+s # saving current figure +#keymap.help: f1 # display help about active tools +#keymap.quit: ctrl+w, cmd+w, q # close the current figure +#keymap.quit_all: # close all figures +#keymap.grid: g # switching on/off major grids in current axes +#keymap.grid_minor: G # switching on/off minor grids in current axes +#keymap.yscale: l # toggle scaling of y-axes ('log'/'linear') +#keymap.xscale: k, L # toggle scaling of x-axes ('log'/'linear') +#keymap.copy: ctrl+c, cmd+c # copy figure to clipboard + + +## *************************************************************************** +## * ANIMATION * +## *************************************************************************** +#animation.html: none # How to display the animation as HTML in + # the IPython notebook: + # - 'html5' uses HTML5 video tag + # - 'jshtml' creates a JavaScript animation +#animation.writer: ffmpeg # MovieWriter 'backend' to use +#animation.codec: h264 # Codec to use for writing movie +#animation.bitrate: -1 # Controls size/quality trade-off for movie. + # -1 implies let utility auto-determine +#animation.frame_format: png # Controls frame format used by temp files + +## Path to ffmpeg binary. Unqualified paths are resolved by subprocess.Popen. +#animation.ffmpeg_path: ffmpeg +## Additional arguments to pass to ffmpeg. +#animation.ffmpeg_args: + +## Path to ImageMagick's convert binary. Unqualified paths are resolved by +## subprocess.Popen, except that on Windows, we look up an install of +## ImageMagick in the registry (as convert is also the name of a system tool). +#animation.convert_path: convert +## Additional arguments to pass to convert. +#animation.convert_args: -layers, OptimizePlus +# +#animation.embed_limit: 20.0 # Limit, in MB, of size of base64 encoded + # animation in HTML (i.e. IPython notebook) diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/plot_directive/plot_directive.css b/lib/python3.12/site-packages/matplotlib/mpl-data/plot_directive/plot_directive.css new file mode 100644 index 0000000000000000000000000000000000000000..d45593c93c95d3718fb9e53a780c8022371e619c --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/plot_directive/plot_directive.css @@ -0,0 +1,16 @@ +/* + * plot_directive.css + * ~~~~~~~~~~~~ + * + * Stylesheet controlling images created using the `plot` directive within + * Sphinx. + * + * :copyright: Copyright 2020-* by the Matplotlib development team. + * :license: Matplotlib, see LICENSE for details. + * + */ + +img.plot-directive { + border: 0; + max-width: 100%; +} diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/bmh.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/bmh.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..1b449cc09fbf25c79ee4cc4f93e26a889bef0b52 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/bmh.mplstyle @@ -0,0 +1,29 @@ +#Author: Cameron Davidson-Pilon, original styles from Bayesian Methods for Hackers +# https://github.com/CamDavidsonPilon/Probabilistic-Programming-and-Bayesian-Methods-for-Hackers/ + +lines.linewidth : 2.0 + +patch.linewidth: 0.5 +patch.facecolor: blue +patch.edgecolor: eeeeee +patch.antialiased: True + +text.hinting_factor : 8 + +mathtext.fontset : cm + +axes.facecolor: eeeeee +axes.edgecolor: bcbcbc +axes.grid : True +axes.titlesize: x-large +axes.labelsize: large +axes.prop_cycle: cycler('color', ['348ABD', 'A60628', '7A68A6', '467821', 'D55E00', 'CC79A7', '56B4E9', '009E73', 'F0E442', '0072B2']) + +grid.color: b2b2b2 +grid.linestyle: -- +grid.linewidth: 0.5 + +legend.fancybox: True + +xtick.direction: in +ytick.direction: in diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/dark_background.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/dark_background.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..61a99f3c0d10c3676ec466b53f716d69df40bb1f --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/dark_background.mplstyle @@ -0,0 +1,26 @@ +# Set black background default line colors to white. + +lines.color: white +patch.edgecolor: white + +text.color: white + +axes.facecolor: black +axes.edgecolor: white +axes.labelcolor: white +axes.prop_cycle: cycler('color', ['8dd3c7', 'feffb3', 'bfbbd9', 'fa8174', '81b1d2', 'fdb462', 'b3de69', 'bc82bd', 'ccebc4', 'ffed6f']) + +xtick.color: white +ytick.color: white + +grid.color: white + +figure.facecolor: black +figure.edgecolor: black + +### Boxplots +boxplot.boxprops.color: white +boxplot.capprops.color: white +boxplot.flierprops.color: white +boxplot.flierprops.markeredgecolor: white +boxplot.whiskerprops.color: white diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/fast.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/fast.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..1f7be7d4632a352f5c96959c8ecdba06c9a06b86 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/fast.mplstyle @@ -0,0 +1,11 @@ +# a small set of changes that will make your plotting FAST (1). +# +# (1) in some cases + +# Maximally simplify lines. +path.simplify: True +path.simplify_threshold: 1.0 + +# chunk up large lines into smaller lines! +# simple trick to avoid those pesky O(>n) algorithms! +agg.path.chunksize: 10000 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/ggplot.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/ggplot.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..d1b3ba2e337b02760ad0c3429f8391b50dea6622 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/ggplot.mplstyle @@ -0,0 +1,39 @@ +# from https://everyhue.me/posts/sane-color-scheme-for-matplotlib/ + +patch.linewidth: 0.5 +patch.facecolor: 348ABD # blue +patch.edgecolor: EEEEEE +patch.antialiased: True + +font.size: 10.0 + +axes.facecolor: E5E5E5 +axes.edgecolor: white +axes.linewidth: 1 +axes.grid: True +axes.titlesize: x-large +axes.labelsize: large +axes.labelcolor: 555555 +axes.axisbelow: True # grid/ticks are below elements (e.g., lines, text) + +axes.prop_cycle: cycler('color', ['E24A33', '348ABD', '988ED5', '777777', 'FBC15E', '8EBA42', 'FFB5B8']) + # E24A33 : red + # 348ABD : blue + # 988ED5 : purple + # 777777 : gray + # FBC15E : yellow + # 8EBA42 : green + # FFB5B8 : pink + +xtick.color: 555555 +xtick.direction: out + +ytick.color: 555555 +ytick.direction: out + +grid.color: white +grid.linestyle: - # solid line + +figure.facecolor: white +figure.edgecolor: 0.50 + diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/grayscale.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/grayscale.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..6a1114e4069882f09c8e518f362a651a5b90ed9d --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/grayscale.mplstyle @@ -0,0 +1,29 @@ +# Set all colors to grayscale +# Note: strings of float values are interpreted by matplotlib as gray values. + + +lines.color: black +patch.facecolor: gray +patch.edgecolor: black + +text.color: black + +axes.facecolor: white +axes.edgecolor: black +axes.labelcolor: black +# black to light gray +axes.prop_cycle: cycler('color', ['0.00', '0.40', '0.60', '0.70']) + +xtick.color: black +ytick.color: black + +grid.color: black + +figure.facecolor: 0.75 +figure.edgecolor: white + +image.cmap: gray + +savefig.facecolor: white +savefig.edgecolor: white + diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-bright.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-bright.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..5e9e949378159bf83de825101b088afe23bbc5ec --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-bright.mplstyle @@ -0,0 +1,3 @@ +# Seaborn bright palette +axes.prop_cycle: cycler('color', ['003FFF', '03ED3A', 'E8000B', '8A2BE2', 'FFC400', '00D7FF']) +patch.facecolor: 003FFF diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-colorblind.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-colorblind.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..e13b7aade323933472751664088c68a682cecf6e --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-colorblind.mplstyle @@ -0,0 +1,3 @@ +# Seaborn colorblind palette +axes.prop_cycle: cycler('color', ['0072B2', '009E73', 'D55E00', 'CC79A7', 'F0E442', '56B4E9']) +patch.facecolor: 0072B2 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-dark-palette.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-dark-palette.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..30160ae2506c63788b799fa80e3f3dedef7bca77 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-dark-palette.mplstyle @@ -0,0 +1,3 @@ +# Seaborn dark palette +axes.prop_cycle: cycler('color', ['001C7F', '017517', '8C0900', '7600A1', 'B8860B', '006374']) +patch.facecolor: 001C7F diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-darkgrid.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-darkgrid.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..0f5d955d7df60665cb30067c0fc83907438b319a --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-darkgrid.mplstyle @@ -0,0 +1,30 @@ +# Seaborn common parameters +# .15 = dark_gray +# .8 = light_gray +figure.facecolor: white +text.color: .15 +axes.labelcolor: .15 +legend.frameon: False +legend.numpoints: 1 +legend.scatterpoints: 1 +xtick.direction: out +ytick.direction: out +xtick.color: .15 +ytick.color: .15 +axes.axisbelow: True +image.cmap: Greys +font.family: sans-serif +font.sans-serif: Arial, Liberation Sans, DejaVu Sans, Bitstream Vera Sans, sans-serif +grid.linestyle: - +lines.solid_capstyle: round + +# Seaborn darkgrid parameters +axes.grid: True +axes.facecolor: EAEAF2 +axes.edgecolor: white +axes.linewidth: 0 +grid.color: white +xtick.major.size: 0 +ytick.major.size: 0 +xtick.minor.size: 0 +ytick.minor.size: 0 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-deep.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-deep.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..5d6b7c56009882e8f3a1cee0b8c8c8a52eb1fcfd --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-deep.mplstyle @@ -0,0 +1,3 @@ +# Seaborn deep palette +axes.prop_cycle: cycler('color', ['4C72B0', '55A868', 'C44E52', '8172B2', 'CCB974', '64B5CD']) +patch.facecolor: 4C72B0 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-muted.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-muted.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..4a71646ce9036116fedfb2094d9f21d52ac3e40a --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-muted.mplstyle @@ -0,0 +1,3 @@ +# Seaborn muted palette +axes.prop_cycle: cycler('color', ['4878CF', '6ACC65', 'D65F5F', 'B47CC7', 'C4AD66', '77BEDB']) +patch.facecolor: 4878CF diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-notebook.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-notebook.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..18bcf3e1204208f50834f6438d67d26e20b90482 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-notebook.mplstyle @@ -0,0 +1,21 @@ +# Seaborn notebook context +figure.figsize: 8.0, 5.5 +axes.labelsize: 11 +axes.titlesize: 12 +xtick.labelsize: 10 +ytick.labelsize: 10 +legend.fontsize: 10 + +grid.linewidth: 1 +lines.linewidth: 1.75 +patch.linewidth: .3 +lines.markersize: 7 +lines.markeredgewidth: 0 + +xtick.major.width: 1 +ytick.major.width: 1 +xtick.minor.width: .5 +ytick.minor.width: .5 + +xtick.major.pad: 7 +ytick.major.pad: 7 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-white.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-white.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..dcbe3acf31dad3f06521e254cd54a74252c77839 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-white.mplstyle @@ -0,0 +1,30 @@ +# Seaborn common parameters +# .15 = dark_gray +# .8 = light_gray +figure.facecolor: white +text.color: .15 +axes.labelcolor: .15 +legend.frameon: False +legend.numpoints: 1 +legend.scatterpoints: 1 +xtick.direction: out +ytick.direction: out +xtick.color: .15 +ytick.color: .15 +axes.axisbelow: True +image.cmap: Greys +font.family: sans-serif +font.sans-serif: Arial, Liberation Sans, DejaVu Sans, Bitstream Vera Sans, sans-serif +grid.linestyle: - +lines.solid_capstyle: round + +# Seaborn white parameters +axes.grid: False +axes.facecolor: white +axes.edgecolor: .15 +axes.linewidth: 1.25 +grid.color: .8 +xtick.major.size: 0 +ytick.major.size: 0 +xtick.minor.size: 0 +ytick.minor.size: 0 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-whitegrid.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-whitegrid.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..612e21813e198296fe9ab95dbb73f8509f97e678 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/seaborn-v0_8-whitegrid.mplstyle @@ -0,0 +1,30 @@ +# Seaborn common parameters +# .15 = dark_gray +# .8 = light_gray +figure.facecolor: white +text.color: .15 +axes.labelcolor: .15 +legend.frameon: False +legend.numpoints: 1 +legend.scatterpoints: 1 +xtick.direction: out +ytick.direction: out +xtick.color: .15 +ytick.color: .15 +axes.axisbelow: True +image.cmap: Greys +font.family: sans-serif +font.sans-serif: Arial, Liberation Sans, DejaVu Sans, Bitstream Vera Sans, sans-serif +grid.linestyle: - +lines.solid_capstyle: round + +# Seaborn whitegrid parameters +axes.grid: True +axes.facecolor: white +axes.edgecolor: .8 +axes.linewidth: 1 +grid.color: .8 +xtick.major.size: 0 +ytick.major.size: 0 +xtick.minor.size: 0 +ytick.minor.size: 0 diff --git a/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/tableau-colorblind10.mplstyle b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/tableau-colorblind10.mplstyle new file mode 100644 index 0000000000000000000000000000000000000000..2d8cb0208d5a2af8915bc605931931605229e263 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/mpl-data/stylelib/tableau-colorblind10.mplstyle @@ -0,0 +1,3 @@ +# Tableau colorblind 10 palette +axes.prop_cycle: cycler('color', ['006BA4', 'FF800E', 'ABABAB', '595959', '5F9ED1', 'C85200', '898989', 'A2C8EC', 'FFBC79', 'CFCFCF']) +patch.facecolor: 006BA4 \ No newline at end of file diff --git a/lib/python3.12/site-packages/matplotlib/testing/__init__.py b/lib/python3.12/site-packages/matplotlib/testing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..19113d3996268ca6d3cec637785f7a30da0aae2f --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/__init__.py @@ -0,0 +1,234 @@ +""" +Helper functions for testing. +""" +from pathlib import Path +from tempfile import TemporaryDirectory +import locale +import logging +import os +import subprocess +import sys + +import matplotlib as mpl +from matplotlib import _api + +_log = logging.getLogger(__name__) + + +def set_font_settings_for_testing(): + mpl.rcParams['font.family'] = 'DejaVu Sans' + mpl.rcParams['text.hinting'] = 'none' + mpl.rcParams['text.hinting_factor'] = 8 + + +def set_reproducibility_for_testing(): + mpl.rcParams['svg.hashsalt'] = 'matplotlib' + + +def setup(): + # The baseline images are created in this locale, so we should use + # it during all of the tests. + + try: + locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + except locale.Error: + try: + locale.setlocale(locale.LC_ALL, 'English_United States.1252') + except locale.Error: + _log.warning( + "Could not set locale to English/United States. " + "Some date-related tests may fail.") + + mpl.use('Agg') + + with _api.suppress_matplotlib_deprecation_warning(): + mpl.rcdefaults() # Start with all defaults + + # These settings *must* be hardcoded for running the comparison tests and + # are not necessarily the default values as specified in rcsetup.py. + set_font_settings_for_testing() + set_reproducibility_for_testing() + + +def subprocess_run_for_testing(command, env=None, timeout=60, stdout=None, + stderr=None, check=False, text=True, + capture_output=False): + """ + Create and run a subprocess. + + Thin wrapper around `subprocess.run`, intended for testing. Will + mark fork() failures on Cygwin as expected failures: not a + success, but not indicating a problem with the code either. + + Parameters + ---------- + args : list of str + env : dict[str, str] + timeout : float + stdout, stderr + check : bool + text : bool + Also called ``universal_newlines`` in subprocess. I chose this + name since the main effect is returning bytes (`False`) vs. str + (`True`), though it also tries to normalize newlines across + platforms. + capture_output : bool + Set stdout and stderr to subprocess.PIPE + + Returns + ------- + proc : subprocess.Popen + + See Also + -------- + subprocess.run + + Raises + ------ + pytest.xfail + If platform is Cygwin and subprocess reports a fork() failure. + """ + if capture_output: + stdout = stderr = subprocess.PIPE + try: + proc = subprocess.run( + command, env=env, + timeout=timeout, check=check, + stdout=stdout, stderr=stderr, + text=text + ) + except BlockingIOError: + if sys.platform == "cygwin": + # Might want to make this more specific + import pytest + pytest.xfail("Fork failure") + raise + return proc + + +def subprocess_run_helper(func, *args, timeout, extra_env=None): + """ + Run a function in a sub-process. + + Parameters + ---------- + func : function + The function to be run. It must be in a module that is importable. + *args : str + Any additional command line arguments to be passed in + the first argument to ``subprocess.run``. + extra_env : dict[str, str] + Any additional environment variables to be set for the subprocess. + """ + target = func.__name__ + module = func.__module__ + file = func.__code__.co_filename + proc = subprocess_run_for_testing( + [ + sys.executable, + "-c", + f"import importlib.util;" + f"_spec = importlib.util.spec_from_file_location({module!r}, {file!r});" + f"_module = importlib.util.module_from_spec(_spec);" + f"_spec.loader.exec_module(_module);" + f"_module.{target}()", + *args + ], + env={**os.environ, "SOURCE_DATE_EPOCH": "0", **(extra_env or {})}, + timeout=timeout, check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + return proc + + +def _check_for_pgf(texsystem): + """ + Check if a given TeX system + pgf is available + + Parameters + ---------- + texsystem : str + The executable name to check + """ + with TemporaryDirectory() as tmpdir: + tex_path = Path(tmpdir, "test.tex") + tex_path.write_text(r""" + \documentclass{article} + \usepackage{pgf} + \begin{document} + \typeout{pgfversion=\pgfversion} + \makeatletter + \@@end + """, encoding="utf-8") + try: + subprocess.check_call( + [texsystem, "-halt-on-error", str(tex_path)], cwd=tmpdir, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (OSError, subprocess.CalledProcessError): + return False + return True + + +def _has_tex_package(package): + try: + mpl.dviread.find_tex_file(f"{package}.sty") + return True + except FileNotFoundError: + return False + + +def ipython_in_subprocess(requested_backend_or_gui_framework, all_expected_backends): + import pytest + IPython = pytest.importorskip("IPython") + + if sys.platform == "win32": + pytest.skip("Cannot change backend running IPython in subprocess on Windows") + + if (IPython.version_info[:3] == (8, 24, 0) and + requested_backend_or_gui_framework == "osx"): + pytest.skip("Bug using macosx backend in IPython 8.24.0 fixed in 8.24.1") + + # This code can be removed when Python 3.12, the latest version supported + # by IPython < 8.24, reaches end-of-life in late 2028. + for min_version, backend in all_expected_backends.items(): + if IPython.version_info[:2] >= min_version: + expected_backend = backend + break + + code = ("import matplotlib as mpl, matplotlib.pyplot as plt;" + "fig, ax=plt.subplots(); ax.plot([1, 3, 2]); mpl.get_backend()") + proc = subprocess_run_for_testing( + [ + "ipython", + "--no-simple-prompt", + f"--matplotlib={requested_backend_or_gui_framework}", + "-c", code, + ], + check=True, + capture_output=True, + ) + + assert proc.stdout.strip().endswith(f"'{expected_backend}'") + + +def is_ci_environment(): + # Common CI variables + ci_environment_variables = [ + 'CI', # Generic CI environment variable + 'CONTINUOUS_INTEGRATION', # Generic CI environment variable + 'TRAVIS', # Travis CI + 'CIRCLECI', # CircleCI + 'JENKINS', # Jenkins + 'GITLAB_CI', # GitLab CI + 'GITHUB_ACTIONS', # GitHub Actions + 'TEAMCITY_VERSION' # TeamCity + # Add other CI environment variables as needed + ] + + for env_var in ci_environment_variables: + if os.getenv(env_var): + return True + + return False diff --git a/lib/python3.12/site-packages/matplotlib/testing/__init__.pyi b/lib/python3.12/site-packages/matplotlib/testing/__init__.pyi new file mode 100644 index 0000000000000000000000000000000000000000..6917b6a5a38080b389f5d970beb8973c1c041206 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/__init__.pyi @@ -0,0 +1,54 @@ +from collections.abc import Callable +import subprocess +from typing import Any, IO, Literal, overload + +def set_font_settings_for_testing() -> None: ... +def set_reproducibility_for_testing() -> None: ... +def setup() -> None: ... +@overload +def subprocess_run_for_testing( + command: list[str], + env: dict[str, str] | None = ..., + timeout: float | None = ..., + stdout: int | IO[Any] | None = ..., + stderr: int | IO[Any] | None = ..., + check: bool = ..., + *, + text: Literal[True], + capture_output: bool = ..., +) -> subprocess.CompletedProcess[str]: ... +@overload +def subprocess_run_for_testing( + command: list[str], + env: dict[str, str] | None = ..., + timeout: float | None = ..., + stdout: int | IO[Any] | None = ..., + stderr: int | IO[Any] | None = ..., + check: bool = ..., + text: Literal[False] = ..., + capture_output: bool = ..., +) -> subprocess.CompletedProcess[bytes]: ... +@overload +def subprocess_run_for_testing( + command: list[str], + env: dict[str, str] | None = ..., + timeout: float | None = ..., + stdout: int | IO[Any] | None = ..., + stderr: int | IO[Any] | None = ..., + check: bool = ..., + text: bool = ..., + capture_output: bool = ..., +) -> subprocess.CompletedProcess[bytes] | subprocess.CompletedProcess[str]: ... +def subprocess_run_helper( + func: Callable[[], None], + *args: Any, + timeout: float, + extra_env: dict[str, str] | None = ..., +) -> subprocess.CompletedProcess[str]: ... +def _check_for_pgf(texsystem: str) -> bool: ... +def _has_tex_package(package: str) -> bool: ... +def ipython_in_subprocess( + requested_backend_or_gui_framework: str, + all_expected_backends: dict[tuple[int, int], str], +) -> None: ... +def is_ci_environment() -> bool: ... diff --git a/lib/python3.12/site-packages/matplotlib/testing/_markers.py b/lib/python3.12/site-packages/matplotlib/testing/_markers.py new file mode 100644 index 0000000000000000000000000000000000000000..c7ef8687a8b39766ba0385b5145b841043251de5 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/_markers.py @@ -0,0 +1,49 @@ +""" +pytest markers for the internal Matplotlib test suite. +""" + +import logging +import shutil + +import pytest + +import matplotlib.testing +import matplotlib.testing.compare +from matplotlib import _get_executable_info, ExecutableNotFoundError + + +_log = logging.getLogger(__name__) + + +def _checkdep_usetex() -> bool: + if not shutil.which("tex"): + _log.warning("usetex mode requires TeX.") + return False + try: + _get_executable_info("dvipng") + except ExecutableNotFoundError: + _log.warning("usetex mode requires dvipng.") + return False + try: + _get_executable_info("gs") + except ExecutableNotFoundError: + _log.warning("usetex mode requires ghostscript.") + return False + return True + + +needs_ghostscript = pytest.mark.skipif( + "eps" not in matplotlib.testing.compare.converter, + reason="This test needs a ghostscript installation") +needs_pgf_lualatex = pytest.mark.skipif( + not matplotlib.testing._check_for_pgf('lualatex'), + reason='lualatex + pgf is required') +needs_pgf_pdflatex = pytest.mark.skipif( + not matplotlib.testing._check_for_pgf('pdflatex'), + reason='pdflatex + pgf is required') +needs_pgf_xelatex = pytest.mark.skipif( + not matplotlib.testing._check_for_pgf('xelatex'), + reason='xelatex + pgf is required') +needs_usetex = pytest.mark.skipif( + not _checkdep_usetex(), + reason="This test needs a TeX installation") diff --git a/lib/python3.12/site-packages/matplotlib/testing/compare.py b/lib/python3.12/site-packages/matplotlib/testing/compare.py new file mode 100644 index 0000000000000000000000000000000000000000..3aeddcbe2b5e9cf99a665171cf5c58e33fc90497 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/compare.py @@ -0,0 +1,527 @@ +""" +Utilities for comparing image results. +""" + +import atexit +import functools +import hashlib +import logging +import os +from pathlib import Path +import shutil +import subprocess +import sys +from tempfile import TemporaryDirectory, TemporaryFile +import weakref +import re + +import numpy as np +from PIL import Image + +import matplotlib as mpl +from matplotlib import cbook +from matplotlib.testing.exceptions import ImageComparisonFailure + +_log = logging.getLogger(__name__) + +__all__ = ['calculate_rms', 'comparable_formats', 'compare_images'] + + +def make_test_filename(fname, purpose): + """ + Make a new filename by inserting *purpose* before the file's extension. + """ + base, ext = os.path.splitext(fname) + return f'{base}-{purpose}{ext}' + + +def _get_cache_path(): + cache_dir = Path(mpl.get_cachedir(), 'test_cache') + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def get_cache_dir(): + return str(_get_cache_path()) + + +def get_file_hash(path, block_size=2 ** 20): + sha256 = hashlib.sha256(usedforsecurity=False) + with open(path, 'rb') as fd: + while True: + data = fd.read(block_size) + if not data: + break + sha256.update(data) + + if Path(path).suffix == '.pdf': + sha256.update(str(mpl._get_executable_info("gs").version).encode('utf-8')) + elif Path(path).suffix == '.svg': + sha256.update(str(mpl._get_executable_info("inkscape").version).encode('utf-8')) + + return sha256.hexdigest() + + +class _ConverterError(Exception): + pass + + +class _Converter: + def __init__(self): + self._proc = None + # Explicitly register deletion from an atexit handler because if we + # wait until the object is GC'd (which occurs later), then some module + # globals (e.g. signal.SIGKILL) has already been set to None, and + # kill() doesn't work anymore... + atexit.register(self.__del__) + + def __del__(self): + if self._proc: + self._proc.kill() + self._proc.wait() + for stream in filter(None, [self._proc.stdin, + self._proc.stdout, + self._proc.stderr]): + stream.close() + self._proc = None + + def _read_until(self, terminator): + """Read until the prompt is reached.""" + buf = bytearray() + while True: + c = self._proc.stdout.read(1) + if not c: + raise _ConverterError(os.fsdecode(bytes(buf))) + buf.extend(c) + if buf.endswith(terminator): + return bytes(buf) + + +class _GSConverter(_Converter): + def __call__(self, orig, dest): + if not self._proc: + self._proc = subprocess.Popen( + [mpl._get_executable_info("gs").executable, + "-dNOSAFER", "-dNOPAUSE", "-dEPSCrop", "-sDEVICE=png16m"], + # As far as I can see, ghostscript never outputs to stderr. + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + try: + self._read_until(b"\nGS") + except _ConverterError as e: + raise OSError(f"Failed to start Ghostscript:\n\n{e.args[0]}") from None + + def encode_and_escape(name): + return (os.fsencode(name) + .replace(b"\\", b"\\\\") + .replace(b"(", br"\(") + .replace(b")", br"\)")) + + self._proc.stdin.write( + b"<< /OutputFile (" + + encode_and_escape(dest) + + b") >> setpagedevice (" + + encode_and_escape(orig) + + b") run flush\n") + self._proc.stdin.flush() + # GS> if nothing left on the stack; GS if n items left on the stack. + err = self._read_until((b"GS<", b"GS>")) + stack = self._read_until(b">") if err.endswith(b"GS<") else b"" + if stack or not os.path.exists(dest): + stack_size = int(stack[:-1]) if stack else 0 + self._proc.stdin.write(b"pop\n" * stack_size) + # Using the systemencoding should at least get the filenames right. + raise ImageComparisonFailure( + (err + stack).decode(sys.getfilesystemencoding(), "replace")) + + +class _SVGConverter(_Converter): + def __call__(self, orig, dest): + old_inkscape = mpl._get_executable_info("inkscape").version.major < 1 + terminator = b"\n>" if old_inkscape else b"> " + if not hasattr(self, "_tmpdir"): + self._tmpdir = TemporaryDirectory() + # On Windows, we must make sure that self._proc has terminated + # (which __del__ does) before clearing _tmpdir. + weakref.finalize(self._tmpdir, self.__del__) + if (not self._proc # First run. + or self._proc.poll() is not None): # Inkscape terminated. + if self._proc is not None and self._proc.poll() is not None: + for stream in filter(None, [self._proc.stdin, + self._proc.stdout, + self._proc.stderr]): + stream.close() + env = { + **os.environ, + # If one passes e.g. a png file to Inkscape, it will try to + # query the user for conversion options via a GUI (even with + # `--without-gui`). Unsetting `DISPLAY` prevents this (and + # causes GTK to crash and Inkscape to terminate, but that'll + # just be reported as a regular exception below). + "DISPLAY": "", + # Do not load any user options. + "INKSCAPE_PROFILE_DIR": self._tmpdir.name, + } + # Old versions of Inkscape (e.g. 0.48.3.1) seem to sometimes + # deadlock when stderr is redirected to a pipe, so we redirect it + # to a temporary file instead. This is not necessary anymore as of + # Inkscape 0.92.1. + stderr = TemporaryFile() + self._proc = subprocess.Popen( + ["inkscape", "--without-gui", "--shell"] if old_inkscape else + ["inkscape", "--shell"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, + env=env, cwd=self._tmpdir.name) + # Slight abuse, but makes shutdown handling easier. + self._proc.stderr = stderr + try: + self._read_until(terminator) + except _ConverterError as err: + raise OSError( + "Failed to start Inkscape in interactive mode:\n\n" + + err.args[0]) from err + + # Inkscape's shell mode does not support escaping metacharacters in the + # filename ("\n", and ":;" for inkscape>=1). Avoid any problems by + # running from a temporary directory and using fixed filenames. + inkscape_orig = Path(self._tmpdir.name, os.fsdecode(b"f.svg")) + inkscape_dest = Path(self._tmpdir.name, os.fsdecode(b"f.png")) + try: + inkscape_orig.symlink_to(Path(orig).resolve()) + except OSError: + shutil.copyfile(orig, inkscape_orig) + self._proc.stdin.write( + b"f.svg --export-png=f.png\n" if old_inkscape else + b"file-open:f.svg;export-filename:f.png;export-do;file-close\n") + self._proc.stdin.flush() + try: + self._read_until(terminator) + except _ConverterError as err: + # Inkscape's output is not localized but gtk's is, so the output + # stream probably has a mixed encoding. Using the filesystem + # encoding should at least get the filenames right... + self._proc.stderr.seek(0) + raise ImageComparisonFailure( + self._proc.stderr.read().decode( + sys.getfilesystemencoding(), "replace")) from err + os.remove(inkscape_orig) + shutil.move(inkscape_dest, dest) + + def __del__(self): + super().__del__() + if hasattr(self, "_tmpdir"): + self._tmpdir.cleanup() + + +class _SVGWithMatplotlibFontsConverter(_SVGConverter): + """ + A SVG converter which explicitly adds the fonts shipped by Matplotlib to + Inkspace's font search path, to better support `svg.fonttype = "none"` + (which is in particular used by certain mathtext tests). + """ + + def __call__(self, orig, dest): + if not hasattr(self, "_tmpdir"): + self._tmpdir = TemporaryDirectory() + shutil.copytree(cbook._get_data_path("fonts/ttf"), + Path(self._tmpdir.name, "fonts")) + return super().__call__(orig, dest) + + +def _update_converter(): + try: + mpl._get_executable_info("gs") + except mpl.ExecutableNotFoundError: + pass + else: + converter['pdf'] = converter['eps'] = _GSConverter() + try: + mpl._get_executable_info("inkscape") + except mpl.ExecutableNotFoundError: + pass + else: + converter['svg'] = _SVGConverter() + + +#: A dictionary that maps filename extensions to functions which themselves +#: convert between arguments `old` and `new` (filenames). +converter = {} +_update_converter() +_svg_with_matplotlib_fonts_converter = _SVGWithMatplotlibFontsConverter() + + +def comparable_formats(): + """ + Return the list of file formats that `.compare_images` can compare + on this system. + + Returns + ------- + list of str + E.g. ``['png', 'pdf', 'svg', 'eps']``. + + """ + return ['png', *converter] + + +def convert(filename, cache): + """ + Convert the named file to png; return the name of the created file. + + If *cache* is True, the result of the conversion is cached in + `matplotlib.get_cachedir() + '/test_cache/'`. The caching is based on a + hash of the exact contents of the input file. Old cache entries are + automatically deleted as needed to keep the size of the cache capped to + twice the size of all baseline images. + """ + path = Path(filename) + if not path.exists(): + raise OSError(f"{path} does not exist") + if path.suffix[1:] not in converter: + import pytest + pytest.skip(f"Don't know how to convert {path.suffix} files to png") + newpath = path.parent / f"{path.stem}_{path.suffix[1:]}.png" + + # Only convert the file if the destination doesn't already exist or + # is out of date. + if not newpath.exists() or newpath.stat().st_mtime < path.stat().st_mtime: + cache_dir = _get_cache_path() if cache else None + + if cache_dir is not None: + _register_conversion_cache_cleaner_once() + hash_value = get_file_hash(path) + cached_path = cache_dir / (hash_value + newpath.suffix) + if cached_path.exists(): + _log.debug("For %s: reusing cached conversion.", filename) + shutil.copyfile(cached_path, newpath) + return str(newpath) + + _log.debug("For %s: converting to png.", filename) + convert = converter[path.suffix[1:]] + if path.suffix == ".svg": + contents = path.read_text() + # NOTE: This check should be kept in sync with font styling in + # `lib/matplotlib/backends/backend_svg.py`. If it changes, then be sure to + # re-generate any SVG test files using this mode, or else such tests will + # fail to use the converter for the expected images (but will for the + # results), and the tests will fail strangely. + if re.search( + # searches for attributes : + # style=[font|font-size|font-weight| + # font-family|font-variant|font-style] + # taking care of the possibility of multiple style attributes + # before the font styling (i.e. opacity) + r'style="[^"]*font(|-size|-weight|-family|-variant|-style):', + contents # raw contents of the svg file + ): + # for svg.fonttype = none, we explicitly patch the font search + # path so that fonts shipped by Matplotlib are found. + convert = _svg_with_matplotlib_fonts_converter + convert(path, newpath) + + if cache_dir is not None: + _log.debug("For %s: caching conversion result.", filename) + shutil.copyfile(newpath, cached_path) + + return str(newpath) + + +def _clean_conversion_cache(): + # This will actually ignore mpl_toolkits baseline images, but they're + # relatively small. + baseline_images_size = sum( + path.stat().st_size + for path in Path(mpl.__file__).parent.glob("**/baseline_images/**/*")) + # 2x: one full copy of baselines, and one full copy of test results + # (actually an overestimate: we don't convert png baselines and results). + max_cache_size = 2 * baseline_images_size + # Reduce cache until it fits. + with cbook._lock_path(_get_cache_path()): + cache_stat = { + path: path.stat() for path in _get_cache_path().glob("*")} + cache_size = sum(stat.st_size for stat in cache_stat.values()) + paths_by_atime = sorted( # Oldest at the end. + cache_stat, key=lambda path: cache_stat[path].st_atime, + reverse=True) + while cache_size > max_cache_size: + path = paths_by_atime.pop() + cache_size -= cache_stat[path].st_size + path.unlink() + + +@functools.cache # Ensure this is only registered once. +def _register_conversion_cache_cleaner_once(): + atexit.register(_clean_conversion_cache) + + +def crop_to_same(actual_path, actual_image, expected_path, expected_image): + # clip the images to the same size -- this is useful only when + # comparing eps to pdf + if actual_path[-7:-4] == 'eps' and expected_path[-7:-4] == 'pdf': + aw, ah, ad = actual_image.shape + ew, eh, ed = expected_image.shape + actual_image = actual_image[int(aw / 2 - ew / 2):int( + aw / 2 + ew / 2), int(ah / 2 - eh / 2):int(ah / 2 + eh / 2)] + return actual_image, expected_image + + +def calculate_rms(expected_image, actual_image): + """ + Calculate the per-pixel errors, then compute the root mean square error. + """ + if expected_image.shape != actual_image.shape: + raise ImageComparisonFailure( + f"Image sizes do not match expected size: {expected_image.shape} " + f"actual size {actual_image.shape}") + # Convert to float to avoid overflowing finite integer types. + return np.sqrt(((expected_image - actual_image).astype(float) ** 2).mean()) + + +# NOTE: compare_image and save_diff_image assume that the image does not have +# 16-bit depth, as Pillow converts these to RGB incorrectly. + + +def _load_image(path): + img = Image.open(path) + # In an RGBA image, if the smallest value in the alpha channel is 255, all + # values in it must be 255, meaning that the image is opaque. If so, + # discard the alpha channel so that it may compare equal to an RGB image. + if img.mode != "RGBA" or img.getextrema()[3][0] == 255: + img = img.convert("RGB") + return np.asarray(img) + + +def compare_images(expected, actual, tol, in_decorator=False): + """ + Compare two "image" files checking differences within a tolerance. + + The two given filenames may point to files which are convertible to + PNG via the `.converter` dictionary. The underlying RMS is calculated + with the `.calculate_rms` function. + + Parameters + ---------- + expected : str + The filename of the expected image. + actual : str + The filename of the actual image. + tol : float + The tolerance (a color value difference, where 255 is the + maximal difference). The test fails if the average pixel + difference is greater than this value. + in_decorator : bool + Determines the output format. If called from image_comparison + decorator, this should be True. (default=False) + + Returns + ------- + None or dict or str + Return *None* if the images are equal within the given tolerance. + + If the images differ, the return value depends on *in_decorator*. + If *in_decorator* is true, a dict with the following entries is + returned: + + - *rms*: The RMS of the image difference. + - *expected*: The filename of the expected image. + - *actual*: The filename of the actual image. + - *diff_image*: The filename of the difference image. + - *tol*: The comparison tolerance. + + Otherwise, a human-readable multi-line string representation of this + information is returned. + + Examples + -------- + :: + + img1 = "./baseline/plot.png" + img2 = "./output/plot.png" + compare_images(img1, img2, 0.001) + + """ + actual = os.fspath(actual) + if not os.path.exists(actual): + raise Exception(f"Output image {actual} does not exist.") + if os.stat(actual).st_size == 0: + raise Exception(f"Output image file {actual} is empty.") + + # Convert the image to png + expected = os.fspath(expected) + if not os.path.exists(expected): + raise OSError(f'Baseline image {expected!r} does not exist.') + extension = expected.split('.')[-1] + if extension != 'png': + actual = convert(actual, cache=True) + expected = convert(expected, cache=True) + + # open the image files + expected_image = _load_image(expected) + actual_image = _load_image(actual) + + actual_image, expected_image = crop_to_same( + actual, actual_image, expected, expected_image) + + diff_image = make_test_filename(actual, 'failed-diff') + + if tol <= 0: + if np.array_equal(expected_image, actual_image): + return None + + # convert to signed integers, so that the images can be subtracted without + # overflow + expected_image = expected_image.astype(np.int16) + actual_image = actual_image.astype(np.int16) + + rms = calculate_rms(expected_image, actual_image) + + if rms <= tol: + return None + + save_diff_image(expected, actual, diff_image) + + results = dict(rms=rms, expected=str(expected), + actual=str(actual), diff=str(diff_image), tol=tol) + + if not in_decorator: + # Then the results should be a string suitable for stdout. + template = ['Error: Image files did not match.', + 'RMS Value: {rms}', + 'Expected: \n {expected}', + 'Actual: \n {actual}', + 'Difference:\n {diff}', + 'Tolerance: \n {tol}', ] + results = '\n '.join([line.format(**results) for line in template]) + return results + + +def save_diff_image(expected, actual, output): + """ + Parameters + ---------- + expected : str + File path of expected image. + actual : str + File path of actual image. + output : str + File path to save difference image to. + """ + expected_image = _load_image(expected) + actual_image = _load_image(actual) + actual_image, expected_image = crop_to_same( + actual, actual_image, expected, expected_image) + expected_image = np.array(expected_image, float) + actual_image = np.array(actual_image, float) + if expected_image.shape != actual_image.shape: + raise ImageComparisonFailure( + f"Image sizes do not match expected size: {expected_image.shape} " + f"actual size {actual_image.shape}") + abs_diff = np.abs(expected_image - actual_image) + + # expand differences in luminance domain + abs_diff *= 10 + abs_diff = np.clip(abs_diff, 0, 255).astype(np.uint8) + + if abs_diff.shape[2] == 4: # Hard-code the alpha channel to fully solid + abs_diff[:, :, 3] = 255 + + Image.fromarray(abs_diff).save(output, format="png") diff --git a/lib/python3.12/site-packages/matplotlib/testing/compare.pyi b/lib/python3.12/site-packages/matplotlib/testing/compare.pyi new file mode 100644 index 0000000000000000000000000000000000000000..8f11b3bebc1a0f4186448b1e8d6a8f11f06e3b4f --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/compare.pyi @@ -0,0 +1,32 @@ +from collections.abc import Callable +from typing import Literal, overload + +from numpy.typing import NDArray + +__all__ = ["calculate_rms", "comparable_formats", "compare_images"] + +def make_test_filename(fname: str, purpose: str) -> str: ... +def get_cache_dir() -> str: ... +def get_file_hash(path: str, block_size: int = ...) -> str: ... + +converter: dict[str, Callable[[str, str], None]] = {} + +def comparable_formats() -> list[str]: ... +def convert(filename: str, cache: bool) -> str: ... +def crop_to_same( + actual_path: str, actual_image: NDArray, expected_path: str, expected_image: NDArray +) -> tuple[NDArray, NDArray]: ... +def calculate_rms(expected_image: NDArray, actual_image: NDArray) -> float: ... +@overload +def compare_images( + expected: str, actual: str, tol: float, in_decorator: Literal[True] +) -> None | dict[str, float | str]: ... +@overload +def compare_images( + expected: str, actual: str, tol: float, in_decorator: Literal[False] +) -> None | str: ... +@overload +def compare_images( + expected: str, actual: str, tol: float, in_decorator: bool = ... +) -> None | str | dict[str, float | str]: ... +def save_diff_image(expected: str, actual: str, output: str) -> None: ... diff --git a/lib/python3.12/site-packages/matplotlib/testing/conftest.py b/lib/python3.12/site-packages/matplotlib/testing/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..2961e7f02f3f9081f9918f090233281e18db2c2e --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/conftest.py @@ -0,0 +1,178 @@ +import pytest +import sys +import matplotlib +from matplotlib import _api + + +def pytest_configure(config): + # config is initialized here rather than in pytest.ini so that `pytest + # --pyargs matplotlib` (which would not find pytest.ini) works. The only + # entries in pytest.ini set minversion (which is checked earlier), + # testpaths/python_files, as they are required to properly find the tests + for key, value in [ + ("markers", "flaky: (Provided by pytest-rerunfailures.)"), + ("markers", "timeout: (Provided by pytest-timeout.)"), + ("markers", "backend: Set alternate Matplotlib backend temporarily."), + ("markers", "baseline_images: Compare output against references."), + ("markers", "pytz: Tests that require pytz to be installed."), + ("filterwarnings", "error"), + ("filterwarnings", + "ignore:.*The py23 module has been deprecated:DeprecationWarning"), + ("filterwarnings", + r"ignore:DynamicImporter.find_spec\(\) not found; " + r"falling back to find_module\(\):ImportWarning"), + ]: + config.addinivalue_line(key, value) + + matplotlib.use('agg', force=True) + matplotlib._called_from_pytest = True + matplotlib._init_tests() + + +def pytest_unconfigure(config): + matplotlib._called_from_pytest = False + + +@pytest.fixture(autouse=True) +def mpl_test_settings(request): + from matplotlib.testing.decorators import _cleanup_cm + + with _cleanup_cm(): + + backend = None + backend_marker = request.node.get_closest_marker('backend') + prev_backend = matplotlib.get_backend() + if backend_marker is not None: + assert len(backend_marker.args) == 1, \ + "Marker 'backend' must specify 1 backend." + backend, = backend_marker.args + skip_on_importerror = backend_marker.kwargs.get( + 'skip_on_importerror', False) + + # special case Qt backend importing to avoid conflicts + if backend.lower().startswith('qt5'): + if any(sys.modules.get(k) for k in ('PyQt4', 'PySide')): + pytest.skip('Qt4 binding already imported') + + matplotlib.testing.setup() + with _api.suppress_matplotlib_deprecation_warning(): + if backend is not None: + # This import must come after setup() so it doesn't load the + # default backend prematurely. + import matplotlib.pyplot as plt + try: + plt.switch_backend(backend) + except ImportError as exc: + # Should only occur for the cairo backend tests, if neither + # pycairo nor cairocffi are installed. + if 'cairo' in backend.lower() or skip_on_importerror: + pytest.skip("Failed to switch to backend " + f"{backend} ({exc}).") + else: + raise + # Default of cleanup and image_comparison too. + matplotlib.style.use(["classic", "_classic_test_patch"]) + try: + yield + finally: + if backend is not None: + plt.close("all") + matplotlib.use(prev_backend) + + +@pytest.fixture +def pd(): + """ + Fixture to import and configure pandas. Using this fixture, the test is skipped when + pandas is not installed. Use this fixture instead of importing pandas in test files. + + Examples + -------- + Request the pandas fixture by passing in ``pd`` as an argument to the test :: + + def test_matshow_pandas(pd): + + df = pd.DataFrame({'x':[1,2,3], 'y':[4,5,6]}) + im = plt.figure().subplots().matshow(df) + np.testing.assert_array_equal(im.get_array(), df) + """ + pd = pytest.importorskip('pandas') + try: + from pandas.plotting import ( + deregister_matplotlib_converters as deregister) + deregister() + except ImportError: + pass + return pd + + +@pytest.fixture +def xr(): + """ + Fixture to import xarray so that the test is skipped when xarray is not installed. + Use this fixture instead of importing xrray in test files. + + Examples + -------- + Request the xarray fixture by passing in ``xr`` as an argument to the test :: + + def test_imshow_xarray(xr): + + ds = xr.DataArray(np.random.randn(2, 3)) + im = plt.figure().subplots().imshow(ds) + np.testing.assert_array_equal(im.get_array(), ds) + """ + + xr = pytest.importorskip('xarray') + return xr + + +@pytest.fixture +def text_placeholders(monkeypatch): + """ + Replace texts with placeholder rectangles. + + The rectangle size only depends on the font size and the number of characters. It is + thus insensitive to font properties and rendering details. This should be used for + tests that depend on text geometries but not the actual text rendering, e.g. layout + tests. + """ + from matplotlib.patches import Rectangle + + def patched_get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): + """ + Replace ``_get_text_metrics_with_cache`` with fixed results. + + The usual ``renderer.get_text_width_height_descent`` would depend on font + metrics; instead the fixed results are based on font size and the length of the + string only. + """ + # While get_window_extent returns pixels and font size is in points, font size + # includes ascenders and descenders. Leaving out this factor and setting + # descent=0 ends up with a box that is relatively close to DejaVu Sans. + height = fontprop.get_size() + width = len(text) * height / 1.618 # Golden ratio for character size. + descent = 0 + return width, height, descent + + def patched_text_draw(self, renderer): + """ + Replace ``Text.draw`` with a fixed bounding box Rectangle. + + The bounding box corresponds to ``Text.get_window_extent``, which ultimately + depends on the above patched ``_get_text_metrics_with_cache``. + """ + if renderer is not None: + self._renderer = renderer + if not self.get_visible(): + return + if self.get_text() == '': + return + bbox = self.get_window_extent() + rect = Rectangle(bbox.p0, bbox.width, bbox.height, + facecolor=self.get_color(), edgecolor='none') + rect.draw(renderer) + + monkeypatch.setattr('matplotlib.text._get_text_metrics_with_cache', + patched_get_text_metrics_with_cache) + monkeypatch.setattr('matplotlib.text.Text.draw', patched_text_draw) diff --git a/lib/python3.12/site-packages/matplotlib/testing/conftest.pyi b/lib/python3.12/site-packages/matplotlib/testing/conftest.pyi new file mode 100644 index 0000000000000000000000000000000000000000..f5d90bc88f738244c463b6a070a756ace8c56202 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/conftest.pyi @@ -0,0 +1,14 @@ +from types import ModuleType + +import pytest + +def pytest_configure(config: pytest.Config) -> None: ... +def pytest_unconfigure(config: pytest.Config) -> None: ... +@pytest.fixture +def mpl_test_settings(request: pytest.FixtureRequest) -> None: ... +@pytest.fixture +def pd() -> ModuleType: ... +@pytest.fixture +def xr() -> ModuleType: ... +@pytest.fixture +def text_placeholders(monkeypatch: pytest.MonkeyPatch) -> None: ... diff --git a/lib/python3.12/site-packages/matplotlib/testing/decorators.py b/lib/python3.12/site-packages/matplotlib/testing/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..6f1af7debdb3160b9975b5278d2864e1b79d64e3 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/decorators.py @@ -0,0 +1,464 @@ +import contextlib +import functools +import inspect +import os +from platform import uname +from pathlib import Path +import shutil +import string +import sys +import warnings + +from packaging.version import parse as parse_version + +import matplotlib.style +import matplotlib.units +import matplotlib.testing +from matplotlib import _pylab_helpers, cbook, ft2font, pyplot as plt, ticker +from .compare import comparable_formats, compare_images, make_test_filename +from .exceptions import ImageComparisonFailure + + +@contextlib.contextmanager +def _cleanup_cm(): + orig_units_registry = matplotlib.units.registry.copy() + try: + with warnings.catch_warnings(), matplotlib.rc_context(): + yield + finally: + matplotlib.units.registry.clear() + matplotlib.units.registry.update(orig_units_registry) + plt.close("all") + + +def _check_freetype_version(ver): + if ver is None: + return True + + if isinstance(ver, str): + ver = (ver, ver) + ver = [parse_version(x) for x in ver] + found = parse_version(ft2font.__freetype_version__) + + return ver[0] <= found <= ver[1] + + +def _checked_on_freetype_version(required_freetype_version): + import pytest + return pytest.mark.xfail( + not _check_freetype_version(required_freetype_version), + reason=f"Mismatched version of freetype. " + f"Test requires '{required_freetype_version}', " + f"you have '{ft2font.__freetype_version__}'", + raises=ImageComparisonFailure, strict=False) + + +def remove_ticks_and_titles(figure): + figure.suptitle("") + null_formatter = ticker.NullFormatter() + def remove_ticks(ax): + """Remove ticks in *ax* and all its child Axes.""" + ax.set_title("") + ax.xaxis.set_major_formatter(null_formatter) + ax.xaxis.set_minor_formatter(null_formatter) + ax.yaxis.set_major_formatter(null_formatter) + ax.yaxis.set_minor_formatter(null_formatter) + try: + ax.zaxis.set_major_formatter(null_formatter) + ax.zaxis.set_minor_formatter(null_formatter) + except AttributeError: + pass + for child in ax.child_axes: + remove_ticks(child) + for ax in figure.get_axes(): + remove_ticks(ax) + + +@contextlib.contextmanager +def _collect_new_figures(): + """ + After:: + + with _collect_new_figures() as figs: + some_code() + + the list *figs* contains the figures that have been created during the + execution of ``some_code``, sorted by figure number. + """ + managers = _pylab_helpers.Gcf.figs + preexisting = [manager for manager in managers.values()] + new_figs = [] + try: + yield new_figs + finally: + new_managers = sorted([manager for manager in managers.values() + if manager not in preexisting], + key=lambda manager: manager.num) + new_figs[:] = [manager.canvas.figure for manager in new_managers] + + +def _raise_on_image_difference(expected, actual, tol): + __tracebackhide__ = True + + err = compare_images(expected, actual, tol, in_decorator=True) + if err: + for key in ["actual", "expected", "diff"]: + err[key] = os.path.relpath(err[key]) + raise ImageComparisonFailure( + ('images not close (RMS %(rms).3f):' + '\n\t%(actual)s\n\t%(expected)s\n\t%(diff)s') % err) + + +class _ImageComparisonBase: + """ + Image comparison base class + + This class provides *just* the comparison-related functionality and avoids + any code that would be specific to any testing framework. + """ + + def __init__(self, func, tol, remove_text, savefig_kwargs): + self.func = func + self.baseline_dir, self.result_dir = _image_directories(func) + self.tol = tol + self.remove_text = remove_text + self.savefig_kwargs = savefig_kwargs + + def copy_baseline(self, baseline, extension): + baseline_path = self.baseline_dir / baseline + orig_expected_path = baseline_path.with_suffix(f'.{extension}') + if extension == 'eps' and not orig_expected_path.exists(): + orig_expected_path = orig_expected_path.with_suffix('.pdf') + expected_fname = make_test_filename( + self.result_dir / orig_expected_path.name, 'expected') + try: + # os.symlink errors if the target already exists. + with contextlib.suppress(OSError): + os.remove(expected_fname) + try: + if 'microsoft' in uname().release.lower(): + raise OSError # On WSL, symlink breaks silently + os.symlink(orig_expected_path, expected_fname) + except OSError: # On Windows, symlink *may* be unavailable. + shutil.copyfile(orig_expected_path, expected_fname) + except OSError as err: + raise ImageComparisonFailure( + f"Missing baseline image {expected_fname} because the " + f"following file cannot be accessed: " + f"{orig_expected_path}") from err + return expected_fname + + def compare(self, fig, baseline, extension, *, _lock=False): + __tracebackhide__ = True + + if self.remove_text: + remove_ticks_and_titles(fig) + + actual_path = (self.result_dir / baseline).with_suffix(f'.{extension}') + kwargs = self.savefig_kwargs.copy() + if extension == 'pdf': + kwargs.setdefault('metadata', + {'Creator': None, 'Producer': None, + 'CreationDate': None}) + + lock = (cbook._lock_path(actual_path) + if _lock else contextlib.nullcontext()) + with lock: + try: + fig.savefig(actual_path, **kwargs) + finally: + # Matplotlib has an autouse fixture to close figures, but this + # makes things more convenient for third-party users. + plt.close(fig) + expected_path = self.copy_baseline(baseline, extension) + _raise_on_image_difference(expected_path, actual_path, self.tol) + + +def _pytest_image_comparison(baseline_images, extensions, tol, + freetype_version, remove_text, savefig_kwargs, + style): + """ + Decorate function with image comparison for pytest. + + This function creates a decorator that wraps a figure-generating function + with image comparison code. + """ + import pytest + + KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY + + def decorator(func): + old_sig = inspect.signature(func) + + @functools.wraps(func) + @pytest.mark.parametrize('extension', extensions) + @matplotlib.style.context(style) + @_checked_on_freetype_version(freetype_version) + @functools.wraps(func) + def wrapper(*args, extension, request, **kwargs): + __tracebackhide__ = True + if 'extension' in old_sig.parameters: + kwargs['extension'] = extension + if 'request' in old_sig.parameters: + kwargs['request'] = request + + if extension not in comparable_formats(): + reason = { + 'pdf': 'because Ghostscript is not installed', + 'eps': 'because Ghostscript is not installed', + 'svg': 'because Inkscape is not installed', + }.get(extension, 'on this system') + pytest.skip(f"Cannot compare {extension} files {reason}") + + img = _ImageComparisonBase(func, tol=tol, remove_text=remove_text, + savefig_kwargs=savefig_kwargs) + matplotlib.testing.set_font_settings_for_testing() + + with _collect_new_figures() as figs: + func(*args, **kwargs) + + # If the test is parametrized in any way other than applied via + # this decorator, then we need to use a lock to prevent two + # processes from touching the same output file. + needs_lock = any( + marker.args[0] != 'extension' + for marker in request.node.iter_markers('parametrize')) + + if baseline_images is not None: + our_baseline_images = baseline_images + else: + # Allow baseline image list to be produced on the fly based on + # current parametrization. + our_baseline_images = request.getfixturevalue( + 'baseline_images') + + assert len(figs) == len(our_baseline_images), ( + f"Test generated {len(figs)} images but there are " + f"{len(our_baseline_images)} baseline images") + for fig, baseline in zip(figs, our_baseline_images): + img.compare(fig, baseline, extension, _lock=needs_lock) + + parameters = list(old_sig.parameters.values()) + if 'extension' not in old_sig.parameters: + parameters += [inspect.Parameter('extension', KEYWORD_ONLY)] + if 'request' not in old_sig.parameters: + parameters += [inspect.Parameter("request", KEYWORD_ONLY)] + new_sig = old_sig.replace(parameters=parameters) + wrapper.__signature__ = new_sig + + # Reach a bit into pytest internals to hoist the marks from our wrapped + # function. + new_marks = getattr(func, 'pytestmark', []) + wrapper.pytestmark + wrapper.pytestmark = new_marks + + return wrapper + + return decorator + + +def image_comparison(baseline_images, extensions=None, tol=0, + freetype_version=None, remove_text=False, + savefig_kwarg=None, + # Default of mpl_test_settings fixture and cleanup too. + style=("classic", "_classic_test_patch")): + """ + Compare images generated by the test with those specified in + *baseline_images*, which must correspond, else an `.ImageComparisonFailure` + exception will be raised. + + Parameters + ---------- + baseline_images : list or None + A list of strings specifying the names of the images generated by + calls to `.Figure.savefig`. + + If *None*, the test function must use the ``baseline_images`` fixture, + either as a parameter or with `pytest.mark.usefixtures`. This value is + only allowed when using pytest. + + extensions : None or list of str + The list of extensions to test, e.g. ``['png', 'pdf']``. + + If *None*, defaults to all supported extensions: png, pdf, and svg. + + When testing a single extension, it can be directly included in the + names passed to *baseline_images*. In that case, *extensions* must not + be set. + + In order to keep the size of the test suite from ballooning, we only + include the ``svg`` or ``pdf`` outputs if the test is explicitly + exercising a feature dependent on that backend (see also the + `check_figures_equal` decorator for that purpose). + + tol : float, default: 0 + The RMS threshold above which the test is considered failed. + + Due to expected small differences in floating-point calculations, on + 32-bit systems an additional 0.06 is added to this threshold. + + freetype_version : str or tuple + The expected freetype version or range of versions for this test to + pass. + + remove_text : bool + Remove the title and tick text from the figure before comparison. This + is useful to make the baseline images independent of variations in text + rendering between different versions of FreeType. + + This does not remove other, more deliberate, text, such as legends and + annotations. + + savefig_kwarg : dict + Optional arguments that are passed to the savefig method. + + style : str, dict, or list + The optional style(s) to apply to the image test. The test itself + can also apply additional styles if desired. Defaults to ``["classic", + "_classic_test_patch"]``. + """ + + if baseline_images is not None: + # List of non-empty filename extensions. + baseline_exts = [*filter(None, {Path(baseline).suffix[1:] + for baseline in baseline_images})] + if baseline_exts: + if extensions is not None: + raise ValueError( + "When including extensions directly in 'baseline_images', " + "'extensions' cannot be set as well") + if len(baseline_exts) > 1: + raise ValueError( + "When including extensions directly in 'baseline_images', " + "all baselines must share the same suffix") + extensions = baseline_exts + baseline_images = [ # Chop suffix out from baseline_images. + Path(baseline).stem for baseline in baseline_images] + if extensions is None: + # Default extensions to test, if not set via baseline_images. + extensions = ['png', 'pdf', 'svg'] + if savefig_kwarg is None: + savefig_kwarg = dict() # default no kwargs to savefig + if sys.maxsize <= 2**32: + tol += 0.06 + return _pytest_image_comparison( + baseline_images=baseline_images, extensions=extensions, tol=tol, + freetype_version=freetype_version, remove_text=remove_text, + savefig_kwargs=savefig_kwarg, style=style) + + +def check_figures_equal(*, extensions=("png", "pdf", "svg"), tol=0): + """ + Decorator for test cases that generate and compare two figures. + + The decorated function must take two keyword arguments, *fig_test* + and *fig_ref*, and draw the test and reference images on them. + After the function returns, the figures are saved and compared. + + This decorator should be preferred over `image_comparison` when possible in + order to keep the size of the test suite from ballooning. + + Parameters + ---------- + extensions : list, default: ["png", "pdf", "svg"] + The extensions to test. + tol : float + The RMS threshold above which the test is considered failed. + + Raises + ------ + RuntimeError + If any new figures are created (and not subsequently closed) inside + the test function. + + Examples + -------- + Check that calling `.Axes.plot` with a single argument plots it against + ``[0, 1, 2, ...]``:: + + @check_figures_equal() + def test_plot(fig_test, fig_ref): + fig_test.subplots().plot([1, 3, 5]) + fig_ref.subplots().plot([0, 1, 2], [1, 3, 5]) + + """ + ALLOWED_CHARS = set(string.digits + string.ascii_letters + '_-[]()') + KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY + + def decorator(func): + import pytest + + _, result_dir = _image_directories(func) + old_sig = inspect.signature(func) + + if not {"fig_test", "fig_ref"}.issubset(old_sig.parameters): + raise ValueError("The decorated function must have at least the " + "parameters 'fig_test' and 'fig_ref', but your " + f"function has the signature {old_sig}") + + @pytest.mark.parametrize("ext", extensions) + def wrapper(*args, ext, request, **kwargs): + if 'ext' in old_sig.parameters: + kwargs['ext'] = ext + if 'request' in old_sig.parameters: + kwargs['request'] = request + + file_name = "".join(c for c in request.node.name + if c in ALLOWED_CHARS) + try: + fig_test = plt.figure("test") + fig_ref = plt.figure("reference") + with _collect_new_figures() as figs: + func(*args, fig_test=fig_test, fig_ref=fig_ref, **kwargs) + if figs: + raise RuntimeError('Number of open figures changed during ' + 'test. Make sure you are plotting to ' + 'fig_test or fig_ref, or if this is ' + 'deliberate explicitly close the ' + 'new figure(s) inside the test.') + test_image_path = result_dir / (file_name + "." + ext) + ref_image_path = result_dir / (file_name + "-expected." + ext) + fig_test.savefig(test_image_path) + fig_ref.savefig(ref_image_path) + _raise_on_image_difference( + ref_image_path, test_image_path, tol=tol + ) + finally: + plt.close(fig_test) + plt.close(fig_ref) + + parameters = [ + param + for param in old_sig.parameters.values() + if param.name not in {"fig_test", "fig_ref"} + ] + if 'ext' not in old_sig.parameters: + parameters += [inspect.Parameter("ext", KEYWORD_ONLY)] + if 'request' not in old_sig.parameters: + parameters += [inspect.Parameter("request", KEYWORD_ONLY)] + new_sig = old_sig.replace(parameters=parameters) + wrapper.__signature__ = new_sig + + # reach a bit into pytest internals to hoist the marks from + # our wrapped function + new_marks = getattr(func, "pytestmark", []) + wrapper.pytestmark + wrapper.pytestmark = new_marks + + return wrapper + + return decorator + + +def _image_directories(func): + """ + Compute the baseline and result image directories for testing *func*. + + For test module ``foo.bar.test_baz``, the baseline directory is at + ``foo/bar/baseline_images/test_baz`` and the result directory at + ``$(pwd)/result_images/test_baz``. The result directory is created if it + doesn't exist. + """ + module_path = Path(inspect.getfile(func)) + baseline_dir = module_path.parent / "baseline_images" / module_path.stem + result_dir = Path().resolve() / "result_images" / module_path.stem + result_dir.mkdir(parents=True, exist_ok=True) + return baseline_dir, result_dir diff --git a/lib/python3.12/site-packages/matplotlib/testing/decorators.pyi b/lib/python3.12/site-packages/matplotlib/testing/decorators.pyi new file mode 100644 index 0000000000000000000000000000000000000000..f1b6c5e595cbca4899cf169a6a85b917ad98bfb4 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/decorators.pyi @@ -0,0 +1,25 @@ +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any, TypeVar +from typing_extensions import ParamSpec + +from matplotlib.figure import Figure +from matplotlib.typing import RcStyleType + +_P = ParamSpec("_P") +_R = TypeVar("_R") + +def remove_ticks_and_titles(figure: Figure) -> None: ... +def image_comparison( + baseline_images: list[str] | None, + extensions: list[str] | None = ..., + tol: float = ..., + freetype_version: tuple[str, str] | str | None = ..., + remove_text: bool = ..., + savefig_kwarg: dict[str, Any] | None = ..., + style: RcStyleType = ..., +) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +def check_figures_equal( + *, extensions: Sequence[str] = ..., tol: float = ... +) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +def _image_directories(func: Callable) -> tuple[Path, Path]: ... diff --git a/lib/python3.12/site-packages/matplotlib/testing/exceptions.py b/lib/python3.12/site-packages/matplotlib/testing/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..c39a39207747c75d768ea2dd498dfed364eb88c5 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/exceptions.py @@ -0,0 +1,4 @@ +class ImageComparisonFailure(AssertionError): + """ + Raise this exception to mark a test as a comparison between two images. + """ diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Duration.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Duration.py new file mode 100644 index 0000000000000000000000000000000000000000..052c5a47c0fd8313795acfcfc2e1aaf851d05c60 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Duration.py @@ -0,0 +1,138 @@ +"""Duration module.""" + +import functools +import operator + +from matplotlib import _api + + +class Duration: + """Class Duration in development.""" + + allowed = ["ET", "UTC"] + + def __init__(self, frame, seconds): + """ + Create a new Duration object. + + = ERROR CONDITIONS + - If the input frame is not in the allowed list, an error is thrown. + + = INPUT VARIABLES + - frame The frame of the duration. Must be 'ET' or 'UTC' + - seconds The number of seconds in the Duration. + """ + _api.check_in_list(self.allowed, frame=frame) + self._frame = frame + self._seconds = seconds + + def frame(self): + """Return the frame the duration is in.""" + return self._frame + + def __abs__(self): + """Return the absolute value of the duration.""" + return Duration(self._frame, abs(self._seconds)) + + def __neg__(self): + """Return the negative value of this Duration.""" + return Duration(self._frame, -self._seconds) + + def seconds(self): + """Return the number of seconds in the Duration.""" + return self._seconds + + def __bool__(self): + return self._seconds != 0 + + def _cmp(self, op, rhs): + """ + Check that *self* and *rhs* share frames; compare them using *op*. + """ + self.checkSameFrame(rhs, "compare") + return op(self._seconds, rhs._seconds) + + __eq__ = functools.partialmethod(_cmp, operator.eq) + __ne__ = functools.partialmethod(_cmp, operator.ne) + __lt__ = functools.partialmethod(_cmp, operator.lt) + __le__ = functools.partialmethod(_cmp, operator.le) + __gt__ = functools.partialmethod(_cmp, operator.gt) + __ge__ = functools.partialmethod(_cmp, operator.ge) + + def __add__(self, rhs): + """ + Add two Durations. + + = ERROR CONDITIONS + - If the input rhs is not in the same frame, an error is thrown. + + = INPUT VARIABLES + - rhs The Duration to add. + + = RETURN VALUE + - Returns the sum of ourselves and the input Duration. + """ + # Delay-load due to circular dependencies. + import matplotlib.testing.jpl_units as U + + if isinstance(rhs, U.Epoch): + return rhs + self + + self.checkSameFrame(rhs, "add") + return Duration(self._frame, self._seconds + rhs._seconds) + + def __sub__(self, rhs): + """ + Subtract two Durations. + + = ERROR CONDITIONS + - If the input rhs is not in the same frame, an error is thrown. + + = INPUT VARIABLES + - rhs The Duration to subtract. + + = RETURN VALUE + - Returns the difference of ourselves and the input Duration. + """ + self.checkSameFrame(rhs, "sub") + return Duration(self._frame, self._seconds - rhs._seconds) + + def __mul__(self, rhs): + """ + Scale a UnitDbl by a value. + + = INPUT VARIABLES + - rhs The scalar to multiply by. + + = RETURN VALUE + - Returns the scaled Duration. + """ + return Duration(self._frame, self._seconds * float(rhs)) + + __rmul__ = __mul__ + + def __str__(self): + """Print the Duration.""" + return f"{self._seconds:g} {self._frame}" + + def __repr__(self): + """Print the Duration.""" + return f"Duration('{self._frame}', {self._seconds:g})" + + def checkSameFrame(self, rhs, func): + """ + Check to see if frames are the same. + + = ERROR CONDITIONS + - If the frame of the rhs Duration is not the same as our frame, + an error is thrown. + + = INPUT VARIABLES + - rhs The Duration to check for the same frame + - func The name of the function doing the check. + """ + if self._frame != rhs._frame: + raise ValueError( + f"Cannot {func} Durations with different frames.\n" + f"LHS: {self._frame}\n" + f"RHS: {rhs._frame}") diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Epoch.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Epoch.py new file mode 100644 index 0000000000000000000000000000000000000000..501b7fa38c792d5bc3c7c683e62e1393a63a4ff7 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/Epoch.py @@ -0,0 +1,211 @@ +"""Epoch module.""" + +import functools +import operator +import math +import datetime as DT + +from matplotlib import _api +from matplotlib.dates import date2num + + +class Epoch: + # Frame conversion offsets in seconds + # t(TO) = t(FROM) + allowed[ FROM ][ TO ] + allowed = { + "ET": { + "UTC": +64.1839, + }, + "UTC": { + "ET": -64.1839, + }, + } + + def __init__(self, frame, sec=None, jd=None, daynum=None, dt=None): + """ + Create a new Epoch object. + + Build an epoch 1 of 2 ways: + + Using seconds past a Julian date: + # Epoch('ET', sec=1e8, jd=2451545) + + or using a matplotlib day number + # Epoch('ET', daynum=730119.5) + + = ERROR CONDITIONS + - If the input units are not in the allowed list, an error is thrown. + + = INPUT VARIABLES + - frame The frame of the epoch. Must be 'ET' or 'UTC' + - sec The number of seconds past the input JD. + - jd The Julian date of the epoch. + - daynum The matplotlib day number of the epoch. + - dt A python datetime instance. + """ + if ((sec is None and jd is not None) or + (sec is not None and jd is None) or + (daynum is not None and + (sec is not None or jd is not None)) or + (daynum is None and dt is None and + (sec is None or jd is None)) or + (daynum is not None and dt is not None) or + (dt is not None and (sec is not None or jd is not None)) or + ((dt is not None) and not isinstance(dt, DT.datetime))): + raise ValueError( + "Invalid inputs. Must enter sec and jd together, " + "daynum by itself, or dt (must be a python datetime).\n" + "Sec = %s\n" + "JD = %s\n" + "dnum= %s\n" + "dt = %s" % (sec, jd, daynum, dt)) + + _api.check_in_list(self.allowed, frame=frame) + self._frame = frame + + if dt is not None: + daynum = date2num(dt) + + if daynum is not None: + # 1-JAN-0001 in JD = 1721425.5 + jd = float(daynum) + 1721425.5 + self._jd = math.floor(jd) + self._seconds = (jd - self._jd) * 86400.0 + + else: + self._seconds = float(sec) + self._jd = float(jd) + + # Resolve seconds down to [ 0, 86400) + deltaDays = math.floor(self._seconds / 86400) + self._jd += deltaDays + self._seconds -= deltaDays * 86400.0 + + def convert(self, frame): + if self._frame == frame: + return self + + offset = self.allowed[self._frame][frame] + + return Epoch(frame, self._seconds + offset, self._jd) + + def frame(self): + return self._frame + + def julianDate(self, frame): + t = self + if frame != self._frame: + t = self.convert(frame) + + return t._jd + t._seconds / 86400.0 + + def secondsPast(self, frame, jd): + t = self + if frame != self._frame: + t = self.convert(frame) + + delta = t._jd - jd + return t._seconds + delta * 86400 + + def _cmp(self, op, rhs): + """Compare Epochs *self* and *rhs* using operator *op*.""" + t = self + if self._frame != rhs._frame: + t = self.convert(rhs._frame) + if t._jd != rhs._jd: + return op(t._jd, rhs._jd) + return op(t._seconds, rhs._seconds) + + __eq__ = functools.partialmethod(_cmp, operator.eq) + __ne__ = functools.partialmethod(_cmp, operator.ne) + __lt__ = functools.partialmethod(_cmp, operator.lt) + __le__ = functools.partialmethod(_cmp, operator.le) + __gt__ = functools.partialmethod(_cmp, operator.gt) + __ge__ = functools.partialmethod(_cmp, operator.ge) + + def __add__(self, rhs): + """ + Add a duration to an Epoch. + + = INPUT VARIABLES + - rhs The Epoch to subtract. + + = RETURN VALUE + - Returns the difference of ourselves and the input Epoch. + """ + t = self + if self._frame != rhs.frame(): + t = self.convert(rhs._frame) + + sec = t._seconds + rhs.seconds() + + return Epoch(t._frame, sec, t._jd) + + def __sub__(self, rhs): + """ + Subtract two Epoch's or a Duration from an Epoch. + + Valid: + Duration = Epoch - Epoch + Epoch = Epoch - Duration + + = INPUT VARIABLES + - rhs The Epoch to subtract. + + = RETURN VALUE + - Returns either the duration between to Epoch's or the a new + Epoch that is the result of subtracting a duration from an epoch. + """ + # Delay-load due to circular dependencies. + import matplotlib.testing.jpl_units as U + + # Handle Epoch - Duration + if isinstance(rhs, U.Duration): + return self + -rhs + + t = self + if self._frame != rhs._frame: + t = self.convert(rhs._frame) + + days = t._jd - rhs._jd + sec = t._seconds - rhs._seconds + + return U.Duration(rhs._frame, days*86400 + sec) + + def __str__(self): + """Print the Epoch.""" + return f"{self.julianDate(self._frame):22.15e} {self._frame}" + + def __repr__(self): + """Print the Epoch.""" + return str(self) + + @staticmethod + def range(start, stop, step): + """ + Generate a range of Epoch objects. + + Similar to the Python range() method. Returns the range [ + start, stop) at the requested step. Each element will be a + Epoch object. + + = INPUT VARIABLES + - start The starting value of the range. + - stop The stop value of the range. + - step Step to use. + + = RETURN VALUE + - Returns a list containing the requested Epoch values. + """ + elems = [] + + i = 0 + while True: + d = start + i * step + if d >= stop: + break + + elems.append(d) + i += 1 + + return elems diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/EpochConverter.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/EpochConverter.py new file mode 100644 index 0000000000000000000000000000000000000000..1edc2acf2b24fe4aab242455dd32679456f69b57 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/EpochConverter.py @@ -0,0 +1,94 @@ +"""EpochConverter module containing class EpochConverter.""" + +from matplotlib import cbook, units +import matplotlib.dates as date_ticker + +__all__ = ['EpochConverter'] + + +class EpochConverter(units.ConversionInterface): + """ + Provides Matplotlib conversion functionality for Monte Epoch and Duration + classes. + """ + + jdRef = 1721425.5 + + @staticmethod + def axisinfo(unit, axis): + # docstring inherited + majloc = date_ticker.AutoDateLocator() + majfmt = date_ticker.AutoDateFormatter(majloc) + return units.AxisInfo(majloc=majloc, majfmt=majfmt, label=unit) + + @staticmethod + def float2epoch(value, unit): + """ + Convert a Matplotlib floating-point date into an Epoch of the specified + units. + + = INPUT VARIABLES + - value The Matplotlib floating-point date. + - unit The unit system to use for the Epoch. + + = RETURN VALUE + - Returns the value converted to an Epoch in the specified time system. + """ + # Delay-load due to circular dependencies. + import matplotlib.testing.jpl_units as U + + secPastRef = value * 86400.0 * U.UnitDbl(1.0, 'sec') + return U.Epoch(unit, secPastRef, EpochConverter.jdRef) + + @staticmethod + def epoch2float(value, unit): + """ + Convert an Epoch value to a float suitable for plotting as a python + datetime object. + + = INPUT VARIABLES + - value An Epoch or list of Epochs that need to be converted. + - unit The units to use for an axis with Epoch data. + + = RETURN VALUE + - Returns the value parameter converted to floats. + """ + return value.julianDate(unit) - EpochConverter.jdRef + + @staticmethod + def duration2float(value): + """ + Convert a Duration value to a float suitable for plotting as a python + datetime object. + + = INPUT VARIABLES + - value A Duration or list of Durations that need to be converted. + + = RETURN VALUE + - Returns the value parameter converted to floats. + """ + return value.seconds() / 86400.0 + + @staticmethod + def convert(value, unit, axis): + # docstring inherited + + # Delay-load due to circular dependencies. + import matplotlib.testing.jpl_units as U + + if not cbook.is_scalar_or_string(value): + return [EpochConverter.convert(x, unit, axis) for x in value] + if unit is None: + unit = EpochConverter.default_units(value, axis) + if isinstance(value, U.Duration): + return EpochConverter.duration2float(value) + else: + return EpochConverter.epoch2float(value, unit) + + @staticmethod + def default_units(value, axis): + # docstring inherited + if cbook.is_scalar_or_string(value): + return value.frame() + else: + return EpochConverter.default_units(value[0], axis) diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/StrConverter.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/StrConverter.py new file mode 100644 index 0000000000000000000000000000000000000000..a62d4981dc79201214dc926eaa6a4c74ffcba078 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/StrConverter.py @@ -0,0 +1,97 @@ +"""StrConverter module containing class StrConverter.""" + +import numpy as np + +import matplotlib.units as units + +__all__ = ['StrConverter'] + + +class StrConverter(units.ConversionInterface): + """ + A Matplotlib converter class for string data values. + + Valid units for string are: + - 'indexed' : Values are indexed as they are specified for plotting. + - 'sorted' : Values are sorted alphanumerically. + - 'inverted' : Values are inverted so that the first value is on top. + - 'sorted-inverted' : A combination of 'sorted' and 'inverted' + """ + + @staticmethod + def axisinfo(unit, axis): + # docstring inherited + return None + + @staticmethod + def convert(value, unit, axis): + # docstring inherited + + if value == []: + return [] + + # we delay loading to make matplotlib happy + ax = axis.axes + if axis is ax.xaxis: + isXAxis = True + else: + isXAxis = False + + axis.get_major_ticks() + ticks = axis.get_ticklocs() + labels = axis.get_ticklabels() + + labels = [l.get_text() for l in labels if l.get_text()] + + if not labels: + ticks = [] + labels = [] + + if not np.iterable(value): + value = [value] + + newValues = [] + for v in value: + if v not in labels and v not in newValues: + newValues.append(v) + + labels.extend(newValues) + + # DISABLED: This is disabled because matplotlib bar plots do not + # DISABLED: recalculate the unit conversion of the data values + # DISABLED: this is due to design and is not really a bug. + # DISABLED: If this gets changed, then we can activate the following + # DISABLED: block of code. Note that this works for line plots. + # DISABLED if unit: + # DISABLED if unit.find("sorted") > -1: + # DISABLED labels.sort() + # DISABLED if unit.find("inverted") > -1: + # DISABLED labels = labels[::-1] + + # add padding (so they do not appear on the axes themselves) + labels = [''] + labels + [''] + ticks = list(range(len(labels))) + ticks[0] = 0.5 + ticks[-1] = ticks[-1] - 0.5 + + axis.set_ticks(ticks) + axis.set_ticklabels(labels) + # we have to do the following lines to make ax.autoscale_view work + loc = axis.get_major_locator() + loc.set_bounds(ticks[0], ticks[-1]) + + if isXAxis: + ax.set_xlim(ticks[0], ticks[-1]) + else: + ax.set_ylim(ticks[0], ticks[-1]) + + result = [ticks[labels.index(v)] for v in value] + + ax.viewLim.ignore(-1) + return result + + @staticmethod + def default_units(value, axis): + # docstring inherited + # The default behavior for string indexing. + return "indexed" diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDbl.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDbl.py new file mode 100644 index 0000000000000000000000000000000000000000..5226c06ad54b9f7718a59e7ed19198d442c04f31 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDbl.py @@ -0,0 +1,180 @@ +"""UnitDbl module.""" + +import functools +import operator + +from matplotlib import _api + + +class UnitDbl: + """Class UnitDbl in development.""" + + # Unit conversion table. Small subset of the full one but enough + # to test the required functions. First field is a scale factor to + # convert the input units to the units of the second field. Only + # units in this table are allowed. + allowed = { + "m": (0.001, "km"), + "km": (1, "km"), + "mile": (1.609344, "km"), + + "rad": (1, "rad"), + "deg": (1.745329251994330e-02, "rad"), + + "sec": (1, "sec"), + "min": (60.0, "sec"), + "hour": (3600, "sec"), + } + + _types = { + "km": "distance", + "rad": "angle", + "sec": "time", + } + + def __init__(self, value, units): + """ + Create a new UnitDbl object. + + Units are internally converted to km, rad, and sec. The only + valid inputs for units are [m, km, mile, rad, deg, sec, min, hour]. + + The field UnitDbl.value will contain the converted value. Use + the convert() method to get a specific type of units back. + + = ERROR CONDITIONS + - If the input units are not in the allowed list, an error is thrown. + + = INPUT VARIABLES + - value The numeric value of the UnitDbl. + - units The string name of the units the value is in. + """ + data = _api.check_getitem(self.allowed, units=units) + self._value = float(value * data[0]) + self._units = data[1] + + def convert(self, units): + """ + Convert the UnitDbl to a specific set of units. + + = ERROR CONDITIONS + - If the input units are not in the allowed list, an error is thrown. + + = INPUT VARIABLES + - units The string name of the units to convert to. + + = RETURN VALUE + - Returns the value of the UnitDbl in the requested units as a floating + point number. + """ + if self._units == units: + return self._value + data = _api.check_getitem(self.allowed, units=units) + if self._units != data[1]: + raise ValueError(f"Error trying to convert to different units.\n" + f" Invalid conversion requested.\n" + f" UnitDbl: {self}\n" + f" Units: {units}\n") + return self._value / data[0] + + def __abs__(self): + """Return the absolute value of this UnitDbl.""" + return UnitDbl(abs(self._value), self._units) + + def __neg__(self): + """Return the negative value of this UnitDbl.""" + return UnitDbl(-self._value, self._units) + + def __bool__(self): + """Return the truth value of a UnitDbl.""" + return bool(self._value) + + def _cmp(self, op, rhs): + """Check that *self* and *rhs* share units; compare them using *op*.""" + self.checkSameUnits(rhs, "compare") + return op(self._value, rhs._value) + + __eq__ = functools.partialmethod(_cmp, operator.eq) + __ne__ = functools.partialmethod(_cmp, operator.ne) + __lt__ = functools.partialmethod(_cmp, operator.lt) + __le__ = functools.partialmethod(_cmp, operator.le) + __gt__ = functools.partialmethod(_cmp, operator.gt) + __ge__ = functools.partialmethod(_cmp, operator.ge) + + def _binop_unit_unit(self, op, rhs): + """Check that *self* and *rhs* share units; combine them using *op*.""" + self.checkSameUnits(rhs, op.__name__) + return UnitDbl(op(self._value, rhs._value), self._units) + + __add__ = functools.partialmethod(_binop_unit_unit, operator.add) + __sub__ = functools.partialmethod(_binop_unit_unit, operator.sub) + + def _binop_unit_scalar(self, op, scalar): + """Combine *self* and *scalar* using *op*.""" + return UnitDbl(op(self._value, scalar), self._units) + + __mul__ = functools.partialmethod(_binop_unit_scalar, operator.mul) + __rmul__ = functools.partialmethod(_binop_unit_scalar, operator.mul) + + def __str__(self): + """Print the UnitDbl.""" + return f"{self._value:g} *{self._units}" + + def __repr__(self): + """Print the UnitDbl.""" + return f"UnitDbl({self._value:g}, '{self._units}')" + + def type(self): + """Return the type of UnitDbl data.""" + return self._types[self._units] + + @staticmethod + def range(start, stop, step=None): + """ + Generate a range of UnitDbl objects. + + Similar to the Python range() method. Returns the range [ + start, stop) at the requested step. Each element will be a + UnitDbl object. + + = INPUT VARIABLES + - start The starting value of the range. + - stop The stop value of the range. + - step Optional step to use. If set to None, then a UnitDbl of + value 1 w/ the units of the start is used. + + = RETURN VALUE + - Returns a list containing the requested UnitDbl values. + """ + if step is None: + step = UnitDbl(1, start._units) + + elems = [] + + i = 0 + while True: + d = start + i * step + if d >= stop: + break + + elems.append(d) + i += 1 + + return elems + + def checkSameUnits(self, rhs, func): + """ + Check to see if units are the same. + + = ERROR CONDITIONS + - If the units of the rhs UnitDbl are not the same as our units, + an error is thrown. + + = INPUT VARIABLES + - rhs The UnitDbl to check for the same units + - func The name of the function doing the check. + """ + if self._units != rhs._units: + raise ValueError(f"Cannot {func} units of different types.\n" + f"LHS: {self._units}\n" + f"RHS: {rhs._units}") diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblConverter.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblConverter.py new file mode 100644 index 0000000000000000000000000000000000000000..23065379f58193ae2f240d7bff44aaf70f8a8dca --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblConverter.py @@ -0,0 +1,85 @@ +"""UnitDblConverter module containing class UnitDblConverter.""" + +import numpy as np + +from matplotlib import cbook, units +import matplotlib.projections.polar as polar + +__all__ = ['UnitDblConverter'] + + +# A special function for use with the matplotlib FuncFormatter class +# for formatting axes with radian units. +# This was copied from matplotlib example code. +def rad_fn(x, pos=None): + """Radian function formatter.""" + n = int((x / np.pi) * 2.0 + 0.25) + if n == 0: + return str(x) + elif n == 1: + return r'$\pi/2$' + elif n == 2: + return r'$\pi$' + elif n % 2 == 0: + return fr'${n//2}\pi$' + else: + return fr'${n}\pi/2$' + + +class UnitDblConverter(units.ConversionInterface): + """ + Provides Matplotlib conversion functionality for the Monte UnitDbl class. + """ + # default for plotting + defaults = { + "distance": 'km', + "angle": 'deg', + "time": 'sec', + } + + @staticmethod + def axisinfo(unit, axis): + # docstring inherited + + # Delay-load due to circular dependencies. + import matplotlib.testing.jpl_units as U + + # Check to see if the value used for units is a string unit value + # or an actual instance of a UnitDbl so that we can use the unit + # value for the default axis label value. + if unit: + label = unit if isinstance(unit, str) else unit.label() + else: + label = None + + if label == "deg" and isinstance(axis.axes, polar.PolarAxes): + # If we want degrees for a polar plot, use the PolarPlotFormatter + majfmt = polar.PolarAxes.ThetaFormatter() + else: + majfmt = U.UnitDblFormatter(useOffset=False) + + return units.AxisInfo(majfmt=majfmt, label=label) + + @staticmethod + def convert(value, unit, axis): + # docstring inherited + if not cbook.is_scalar_or_string(value): + return [UnitDblConverter.convert(x, unit, axis) for x in value] + # If no units were specified, then get the default units to use. + if unit is None: + unit = UnitDblConverter.default_units(value, axis) + # Convert the incoming UnitDbl value/values to float/floats + if isinstance(axis.axes, polar.PolarAxes) and value.type() == "angle": + # Guarantee that units are radians for polar plots. + return value.convert("rad") + return value.convert(unit) + + @staticmethod + def default_units(value, axis): + # docstring inherited + # Determine the default units based on the user preferences set for + # default units when printing a UnitDbl. + if cbook.is_scalar_or_string(value): + return UnitDblConverter.defaults[value.type()] + else: + return UnitDblConverter.default_units(value[0], axis) diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblFormatter.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblFormatter.py new file mode 100644 index 0000000000000000000000000000000000000000..30a9914015bc7eb955c88e562a771591b82ad2e8 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/UnitDblFormatter.py @@ -0,0 +1,28 @@ +"""UnitDblFormatter module containing class UnitDblFormatter.""" + +import matplotlib.ticker as ticker + +__all__ = ['UnitDblFormatter'] + + +class UnitDblFormatter(ticker.ScalarFormatter): + """ + The formatter for UnitDbl data types. + + This allows for formatting with the unit string. + """ + + def __call__(self, x, pos=None): + # docstring inherited + if len(self.locs) == 0: + return '' + else: + return f'{x:.12}' + + def format_data_short(self, value): + # docstring inherited + return f'{value:.12}' + + def format_data(self, value): + # docstring inherited + return f'{value:.12}' diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__init__.py b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b8caa9a8957a250b78712c25175bec415507e416 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__init__.py @@ -0,0 +1,76 @@ +""" +A sample set of units for use with testing unit conversion +of Matplotlib routines. These are used because they use very strict +enforcement of unitized data which will test the entire spectrum of how +unitized data might be used (it is not always meaningful to convert to +a float without specific units given). + +UnitDbl is essentially a unitized floating point number. It has a +minimal set of supported units (enough for testing purposes). All +of the mathematical operation are provided to fully test any behaviour +that might occur with unitized data. Remember that unitized data has +rules as to how it can be applied to one another (a value of distance +cannot be added to a value of time). Thus we need to guard against any +accidental "default" conversion that will strip away the meaning of the +data and render it neutered. + +Epoch is different than a UnitDbl of time. Time is something that can be +measured where an Epoch is a specific moment in time. Epochs are typically +referenced as an offset from some predetermined epoch. + +A difference of two epochs is a Duration. The distinction between a Duration +and a UnitDbl of time is made because an Epoch can have different frames (or +units). In the case of our test Epoch class the two allowed frames are 'UTC' +and 'ET' (Note that these are rough estimates provided for testing purposes +and should not be used in production code where accuracy of time frames is +desired). As such a Duration also has a frame of reference and therefore needs +to be called out as different that a simple measurement of time since a delta-t +in one frame may not be the same in another. +""" + +from .Duration import Duration +from .Epoch import Epoch +from .UnitDbl import UnitDbl + +from .StrConverter import StrConverter +from .EpochConverter import EpochConverter +from .UnitDblConverter import UnitDblConverter + +from .UnitDblFormatter import UnitDblFormatter + + +__version__ = "1.0" + +__all__ = [ + 'register', + 'Duration', + 'Epoch', + 'UnitDbl', + 'UnitDblFormatter', + ] + + +def register(): + """Register the unit conversion classes with matplotlib.""" + import matplotlib.units as mplU + + mplU.registry[str] = StrConverter() + mplU.registry[Epoch] = EpochConverter() + mplU.registry[Duration] = EpochConverter() + mplU.registry[UnitDbl] = UnitDblConverter() + + +# Some default unit instances +# Distances +m = UnitDbl(1.0, "m") +km = UnitDbl(1.0, "km") +mile = UnitDbl(1.0, "mile") +# Angles +deg = UnitDbl(1.0, "deg") +rad = UnitDbl(1.0, "rad") +# Time +sec = UnitDbl(1.0, "sec") +min = UnitDbl(1.0, "min") +hr = UnitDbl(1.0, "hour") +day = UnitDbl(24.0, "hour") +sec = UnitDbl(1.0, "sec") diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/Duration.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/Duration.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d12c2cd333b8b7326baea06b12ce03fadeadb93 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/Duration.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/EpochConverter.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/EpochConverter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00d3f3bfbdb827d5899719664bdc195aceb26a93 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/EpochConverter.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/StrConverter.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/StrConverter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3063203ad194c9054f94ff087c272813b0da3a08 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/StrConverter.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDbl.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDbl.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d9614c6800c1c16216ef1351d6d397f0feb3135 Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDbl.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDblFormatter.cpython-312.pyc b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDblFormatter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6753bfb1650232ea91ae70f42d7fba4e1aff38e Binary files /dev/null and b/lib/python3.12/site-packages/matplotlib/testing/jpl_units/__pycache__/UnitDblFormatter.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/matplotlib/testing/widgets.py b/lib/python3.12/site-packages/matplotlib/testing/widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..3962567aa7c03eb978db53e3d6007446bb177d28 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/widgets.py @@ -0,0 +1,119 @@ +""" +======================== +Widget testing utilities +======================== + +See also :mod:`matplotlib.tests.test_widgets`. +""" + +from unittest import mock + +import matplotlib.pyplot as plt + + +def get_ax(): + """Create a plot and return its Axes.""" + fig, ax = plt.subplots(1, 1) + ax.plot([0, 200], [0, 200]) + ax.set_aspect(1.0) + fig.canvas.draw() + return ax + + +def noop(*args, **kwargs): + pass + + +def mock_event(ax, button=1, xdata=0, ydata=0, key=None, step=1): + r""" + Create a mock event that can stand in for `.Event` and its subclasses. + + This event is intended to be used in tests where it can be passed into + event handling functions. + + Parameters + ---------- + ax : `~matplotlib.axes.Axes` + The Axes the event will be in. + xdata : float + x coord of mouse in data coords. + ydata : float + y coord of mouse in data coords. + button : None or `MouseButton` or {'up', 'down'} + The mouse button pressed in this event (see also `.MouseEvent`). + key : None or str + The key pressed when the mouse event triggered (see also `.KeyEvent`). + step : int + Number of scroll steps (positive for 'up', negative for 'down'). + + Returns + ------- + event + A `.Event`\-like Mock instance. + """ + event = mock.Mock() + event.button = button + event.x, event.y = ax.transData.transform([(xdata, ydata), + (xdata, ydata)])[0] + event.xdata, event.ydata = xdata, ydata + event.inaxes = ax + event.canvas = ax.get_figure(root=True).canvas + event.key = key + event.step = step + event.guiEvent = None + event.name = 'Custom' + return event + + +def do_event(tool, etype, button=1, xdata=0, ydata=0, key=None, step=1): + """ + Trigger an event on the given tool. + + Parameters + ---------- + tool : matplotlib.widgets.AxesWidget + etype : str + The event to trigger. + xdata : float + x coord of mouse in data coords. + ydata : float + y coord of mouse in data coords. + button : None or `MouseButton` or {'up', 'down'} + The mouse button pressed in this event (see also `.MouseEvent`). + key : None or str + The key pressed when the mouse event triggered (see also `.KeyEvent`). + step : int + Number of scroll steps (positive for 'up', negative for 'down'). + """ + event = mock_event(tool.ax, button, xdata, ydata, key, step) + func = getattr(tool, etype) + func(event) + + +def click_and_drag(tool, start, end, key=None): + """ + Helper to simulate a mouse drag operation. + + Parameters + ---------- + tool : `~matplotlib.widgets.Widget` + start : [float, float] + Starting point in data coordinates. + end : [float, float] + End point in data coordinates. + key : None or str + An optional key that is pressed during the whole operation + (see also `.KeyEvent`). + """ + if key is not None: + # Press key + do_event(tool, 'on_key_press', xdata=start[0], ydata=start[1], + button=1, key=key) + # Click, move, and release mouse + do_event(tool, 'press', xdata=start[0], ydata=start[1], button=1) + do_event(tool, 'onmove', xdata=end[0], ydata=end[1], button=1) + do_event(tool, 'release', xdata=end[0], ydata=end[1], button=1) + if key is not None: + # Release key + do_event(tool, 'on_key_release', xdata=end[0], ydata=end[1], + button=1, key=key) diff --git a/lib/python3.12/site-packages/matplotlib/testing/widgets.pyi b/lib/python3.12/site-packages/matplotlib/testing/widgets.pyi new file mode 100644 index 0000000000000000000000000000000000000000..858ff457158283bae9db974e05098890ef5ac260 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib/testing/widgets.pyi @@ -0,0 +1,31 @@ +from typing import Any, Literal + +from matplotlib.axes import Axes +from matplotlib.backend_bases import Event, MouseButton +from matplotlib.widgets import AxesWidget, Widget + +def get_ax() -> Axes: ... +def noop(*args: Any, **kwargs: Any) -> None: ... +def mock_event( + ax: Axes, + button: MouseButton | int | Literal["up", "down"] | None = ..., + xdata: float = ..., + ydata: float = ..., + key: str | None = ..., + step: int = ..., +) -> Event: ... +def do_event( + tool: AxesWidget, + etype: str, + button: MouseButton | int | Literal["up", "down"] | None = ..., + xdata: float = ..., + ydata: float = ..., + key: str | None = ..., + step: int = ..., +) -> None: ... +def click_and_drag( + tool: Widget, + start: tuple[float, float], + end: tuple[float, float], + key: str | None = ..., +) -> None: ...