tikzplotly 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {tikzplotly-0.2.1/src/tikzplotly.egg-info → tikzplotly-0.2.3}/PKG-INFO +6 -5
  2. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/README.md +3 -3
  3. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/pyproject.toml +11 -3
  4. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/__about__.py +1 -1
  5. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/__init__.py +9 -1
  6. tikzplotly-0.2.3/src/tikzplotly/_browser.py +346 -0
  7. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_color.py +10 -9
  8. tikzplotly-0.2.1/src/tikzplotly/_dataContainer.py → tikzplotly-0.2.3/src/tikzplotly/_data_container.py +48 -11
  9. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_heatmap.py +3 -2
  10. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_histogram.py +27 -27
  11. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_polar.py +1 -1
  12. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_save.py +11 -10
  13. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_scatter.py +6 -36
  14. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_scatter3d.py +6 -35
  15. tikzplotly-0.2.3/src/tikzplotly/_trace_utils.py +70 -0
  16. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_utils.py +2 -10
  17. {tikzplotly-0.2.1 → tikzplotly-0.2.3/src/tikzplotly.egg-info}/PKG-INFO +6 -5
  18. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/SOURCES.txt +5 -1
  19. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/requires.txt +1 -0
  20. tikzplotly-0.2.3/tests/test_browser.py +157 -0
  21. tikzplotly-0.2.3/tests/test_colors.py +53 -0
  22. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_heatmap.py +12 -5
  23. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_histograms.py +10 -3
  24. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_markers.py +7 -1
  25. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_polar.py +20 -2
  26. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_scatter.py +27 -6
  27. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_scatter3d.py +24 -3
  28. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_specific.py +16 -12
  29. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/LICENSE +0 -0
  30. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/setup.cfg +0 -0
  31. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_bars.py +0 -0
  32. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_heatmaps.py +0 -0
  33. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_histograms.py +0 -0
  34. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_line_charts.py +0 -0
  35. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_markers.py +0 -0
  36. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_polar.py +0 -0
  37. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_scatter3d.py +0 -0
  38. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_annotations.py +0 -0
  39. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_axis.py +0 -0
  40. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_bar.py +0 -0
  41. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_dash.py +0 -0
  42. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_data.py +0 -0
  43. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_marker.py +0 -0
  44. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_tex.py +0 -0
  45. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/dependency_links.txt +0 -0
  46. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/top_level.txt +0 -0
  47. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_bars.py +0 -0
  48. {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_tikzplotly.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tikzplotly
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Convert plotly figures to LaTeX / TikZ figures
5
5
  Author-email: Thomas Saigre <tikzplotly@outlook.fr>
6
6
  License: The MIT License (MIT)
@@ -30,11 +30,11 @@ Keywords: plotly,latex,tikz,figure,conversion,graphics
30
30
  Classifier: Development Status :: 5 - Production/Stable
31
31
  Classifier: Programming Language :: Python
32
32
  Classifier: Programming Language :: Python :: 3
33
- Classifier: Programming Language :: Python :: 3.9
34
33
  Classifier: Programming Language :: Python :: 3.10
35
34
  Classifier: Programming Language :: Python :: 3.11
36
35
  Classifier: Programming Language :: Python :: 3.12
37
36
  Classifier: Programming Language :: Python :: 3.13
37
+ Classifier: Programming Language :: Python :: 3.14
38
38
  Classifier: License :: OSI Approved :: MIT License
39
39
  Classifier: Operating System :: OS Independent
40
40
  Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
@@ -44,6 +44,7 @@ License-File: LICENSE
44
44
  Requires-Dist: numpy
45
45
  Requires-Dist: plotly
46
46
  Requires-Dist: pillow
47
+ Requires-Dist: kaleido
47
48
  Provides-Extra: dev
48
49
  Requires-Dist: pandas; extra == "dev"
49
50
  Dynamic: license-file
@@ -65,7 +66,7 @@ Dynamic: license-file
65
66
 
66
67
  # Tikzplotly
67
68
 
68
- Convert [plotly](https://plotly.com/python/) figures to TikZ code for inclusion into [PGFPlots](https://www.ctan.org/pkg/pgfplots) ([PGF/TikZ](https://www.ctan.org/pkg/pgf)) figures.
69
+ Convert [Plotly](https://plotly.com/python/) figures to TikZ code for inclusion into [PGFPlots](https://www.ctan.org/pkg/pgfplots) ([PGF/TikZ](https://www.ctan.org/pkg/pgf)) figures.
69
70
 
70
71
  This results in a ti*k*z code, that can be easily included into your LaTeX document.
71
72
  This also allows to easily edit the content of the figure.
@@ -151,9 +152,9 @@ To correctly compile the document, you will need to add the following packages t
151
152
 
152
153
  ## Note
153
154
 
154
- * This module is in development and new features are added bit by bit, when needed. If you have a feature request, please open an issue with the plotly figure you want to convert and the desired output.
155
+ * This module is in development and new features are added bit by bit, when needed. If you have a feature request, please open an issue with the Plotly figure you want to convert and the desired output.
155
156
  You can also submit a pull request with the desired feature !
156
- * Some feature can result in different output between the plotly figure and the TikZ figure, for instance the size of markers, more details can be found in [the documentation](https://thomas-saigre.github.io/tikzplotly/).
157
+ * Some feature can result in different output between the Plotly figure and the TikZ figure, for instance the size of markers, more details can be found in [the documentation](https://thomas-saigre.github.io/tikzplotly/).
157
158
 
158
159
 
159
160
  ## References
@@ -15,7 +15,7 @@
15
15
 
16
16
  # Tikzplotly
17
17
 
18
- Convert [plotly](https://plotly.com/python/) figures to TikZ code for inclusion into [PGFPlots](https://www.ctan.org/pkg/pgfplots) ([PGF/TikZ](https://www.ctan.org/pkg/pgf)) figures.
18
+ Convert [Plotly](https://plotly.com/python/) figures to TikZ code for inclusion into [PGFPlots](https://www.ctan.org/pkg/pgfplots) ([PGF/TikZ](https://www.ctan.org/pkg/pgf)) figures.
19
19
 
20
20
  This results in a ti*k*z code, that can be easily included into your LaTeX document.
21
21
  This also allows to easily edit the content of the figure.
@@ -101,9 +101,9 @@ To correctly compile the document, you will need to add the following packages t
101
101
 
102
102
  ## Note
103
103
 
104
- * This module is in development and new features are added bit by bit, when needed. If you have a feature request, please open an issue with the plotly figure you want to convert and the desired output.
104
+ * This module is in development and new features are added bit by bit, when needed. If you have a feature request, please open an issue with the Plotly figure you want to convert and the desired output.
105
105
  You can also submit a pull request with the desired feature !
106
- * Some feature can result in different output between the plotly figure and the TikZ figure, for instance the size of markers, more details can be found in [the documentation](https://thomas-saigre.github.io/tikzplotly/).
106
+ * Some feature can result in different output between the Plotly figure and the TikZ figure, for instance the size of markers, more details can be found in [the documentation](https://thomas-saigre.github.io/tikzplotly/).
107
107
 
108
108
 
109
109
  ## References
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tikzplotly"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Convert plotly figures to LaTeX / TikZ figures"
9
9
  readme = "README.md"
10
10
  authors = [{name = "Thomas Saigre", email = "tikzplotly@outlook.fr"}]
@@ -13,11 +13,11 @@ classifiers = [
13
13
  "Development Status :: 5 - Production/Stable",
14
14
  "Programming Language :: Python",
15
15
  "Programming Language :: Python :: 3",
16
- "Programming Language :: Python :: 3.9",
17
16
  "Programming Language :: Python :: 3.10",
18
17
  "Programming Language :: Python :: 3.11",
19
18
  "Programming Language :: Python :: 3.12",
20
19
  "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
21
  "License :: OSI Approved :: MIT License",
22
22
  "Operating System :: OS Independent",
23
23
  "Topic :: Multimedia :: Graphics :: Graphics Conversion",
@@ -27,7 +27,8 @@ keywords = ["plotly", "latex", "tikz", "figure", "conversion", "graphics"]
27
27
  dependencies = [
28
28
  "numpy",
29
29
  "plotly",
30
- "pillow"
30
+ "pillow",
31
+ "kaleido"
31
32
  ]
32
33
 
33
34
  [project.optional-dependencies]
@@ -36,3 +37,10 @@ dev = ["pandas"]
36
37
  [project.urls]
37
38
  Code = "https://github.com/thomas-saigre/tikzplotly"
38
39
  Issues = "https://github.com/thomas-saigre/tikzplotly/issues"
40
+
41
+
42
+ [tool.pytest.ini_options]
43
+ filterwarnings = [
44
+ "ignore:png image has not been reduced.*:UserWarning",
45
+ "ignore:Text template is not supported yet.:UserWarning",
46
+ ]
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Module metadata for tikzplotly.
3
3
  """
4
- __version__ = "0.2.1"
4
+ __version__ = "0.2.3"
5
5
  __author__ = "Thomas Saigre"
6
6
  __license__ = "MIT"
7
7
  __description__ = "Convert plotly figures to TikZ code for inclusion into PGFPlots figures."
@@ -7,8 +7,16 @@ functions for generating and saving TikZ code from plotly figures.
7
7
  Exports:
8
8
  get_tikz_code (Callable): Function to generate TikZ code from a plotly figure.
9
9
  save (Callable): Function to save TikZ code to a file.
10
+ show (Callable): Function to show a figure in the browser, with a button exporting it to TikZ.
11
+
12
+ Importing the package also registers the ``"tikzplotly"`` Plotly renderer, so that
13
+ ``fig.show(renderer="tikzplotly")`` adds the same button.
10
14
  """
11
15
  from .__about__ import __version__, __author__, __license__, __description__
12
16
  from ._save import get_tikz_code, save
17
+ from ._browser import show, TikzBrowserRenderer, register_renderer as _register_renderer
18
+
19
+ _register_renderer()
13
20
 
14
- __all__ = ["__version__", "__author__", "__license__", "__description__", "get_tikz_code", "save"]
21
+ __all__ = ["__version__", "__author__", "__license__", "__description__", "get_tikz_code", "save",
22
+ "show", "TikzBrowserRenderer"]
@@ -0,0 +1,346 @@
1
+ """Show a figure in the web browser with an "Export to TikZ" button in the mode bar.
2
+
3
+ Plotly's own browser renderer serves the page once and returns, so the page has no way to
4
+ reach Python afterwards. Here a small local HTTP server keeps running while the page is open:
5
+ clicking the button sends the current view (axis ranges, hidden traces) to that server, which
6
+ calls :func:`get_tikz_code` and writes the result to a file.
7
+
8
+ Plotly itself is not modified: a renderer named ``"tikzplotly"`` is registered with Plotly's
9
+ renderer API when tikzplotly is imported, and :func:`show` is a shortcut to it.
10
+ """
11
+ import base64
12
+ import json
13
+ import secrets
14
+ import socketserver
15
+ import threading
16
+ import time
17
+ import webbrowser
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+ from typing import cast
21
+
22
+ import numpy as np
23
+ import plotly.graph_objects as go
24
+ import plotly.io as pio
25
+
26
+ from ._save import get_tikz_code
27
+
28
+ try: # private module: guarded so that a Plotly reorganisation cannot break ``import tikzplotly``
29
+ from plotly.io._base_renderers import ExternalRenderer
30
+ except ImportError: # pragma: no cover
31
+ ExternalRenderer = object
32
+
33
+ RENDERER_NAME = "tikzplotly"
34
+
35
+ # A hidden browser tab may only run its timers once a minute, so the idle timeout has to be
36
+ # well above 60 s, or a tab left in the background would be considered closed.
37
+ PING_MS = 10_000
38
+ IDLE_TIMEOUT = 150
39
+ # Time left after the page signals it is closing, so that a page reload can reconnect.
40
+ CLOSE_GRACE = 5
41
+
42
+ # "Export to TikZ" icon: a floppy disk with "TikZ" (glyph outlines of Latin Modern Roman Bold),
43
+ # in a 1000x1000 box. A single path, because Plotly only colours the first path of an icon,
44
+ # and its holes need the evenodd fill rule, set in _POST_SCRIPT.
45
+ TIKZ_ICON_PATH = (
46
+ "M110 40H760L960 240V890Q960 960 890 960H110Q40 960 40 890V110Q40 40 110 40Z M120 115V885H885V275L725"
47
+ " 115Z M230 115H690V290Q690 320 660 320H260Q230 320 230 290Z M540 155V280H630V155Z M381 604 374 523H1"
48
+ "47L140 604H156C159 566 162 539 211 539H237V735H185V750C203 749 241 749 260 749C280 749 318 749 336 7"
49
+ "50V735H284V539H310C358 539 361 566 365 604ZM491 750V735H471V599L412 602V617C432 617 434 617 434 631V"
50
+ "735H411V750L452 749ZM475 546C475 532 463 520 449 520C435 520 423 532 423 546C423 561 435 572 449 572"
51
+ "C463 572 475 561 475 546ZM673 701C673 696 668 696 663 696C655 696 655 696 653 705C648 725 641 741 62"
52
+ "9 741C626 741 621 740 621 729C621 729 621 723 624 712C625 709 625 705 625 702C625 676 596 669 572 66"
53
+ "6C584 657 596 645 596 644C619 619 634 610 647 610C648 610 652 610 653 614C639 616 630 628 630 639C63"
54
+ "0 647 636 654 647 654C657 654 671 647 671 625C671 615 667 598 648 598C629 598 612 611 591 634C577 64"
55
+ "8 571 653 563 658L596 528C596 527 597 525 597 523C597 516 591 517 586 517L545 519C541 519 534 519 53"
56
+ "4 529C534 535 541 535 544 535C548 535 554 536 558 536L509 731C508 736 508 737 508 739C508 747 514 75"
57
+ "3 523 753C530 753 540 749 543 739L558 678C568 680 589 685 589 703C589 705 589 707 588 710C588 713 58"
58
+ "7 715 587 719C587 739 605 753 627 753C661 753 673 703 673 701ZM865 655H849C846 692 842 733 773 733H7"
59
+ "26L860 536C863 532 863 532 863 528C863 519 859 519 852 519H679L675 601H691C693 556 716 535 763 535H8"
60
+ "07L672 733C669 737 669 738 669 742C669 750 673 750 680 750H859Z"
61
+ )
62
+
63
+ _POST_SCRIPT = """
64
+ (function () {
65
+ var gd = document.getElementById('{plot_id}');
66
+ var token = '__TOKEN__';
67
+
68
+ function post(path, body) {
69
+ return fetch('/' + token + path, {method: 'POST', body: JSON.stringify(body || {})});
70
+ }
71
+
72
+ function toast(message, failed) {
73
+ var el = document.createElement('div');
74
+ el.textContent = message;
75
+ el.style.cssText = 'position:fixed;left:16px;bottom:16px;padding:8px 12px;z-index:9999;'
76
+ + 'border-radius:4px;color:#fff;font:13px sans-serif;background:'
77
+ + (failed ? '#b3261e' : '#2e7d32');
78
+ document.body.appendChild(el);
79
+ setTimeout(function () { el.remove(); }, failed ? 8000 : 3000);
80
+ }
81
+
82
+ // Only the view state is sent: the data stays on the Python side.
83
+ function viewState() {
84
+ var axes = {};
85
+ Object.keys(gd.layout).forEach(function (key) {
86
+ if (/^[xy]axis\\d*$/.test(key)) {
87
+ var axis = gd.layout[key];
88
+ axes[key] = axis.autorange ? null : axis.range;
89
+ }
90
+ });
91
+ return {axes: axes, visible: gd.data.map(function (t) { return t.visible; })};
92
+ }
93
+
94
+ var button = {
95
+ name: 'exportTikz',
96
+ title: 'Export to TikZ',
97
+ icon: {width: 1000, height: 1000, path: '__ICON_PATH__'},
98
+ click: function () {
99
+ post('/export', viewState())
100
+ .then(function (r) { return r.json(); })
101
+ .then(function (r) {
102
+ if (r.ok) { toast('Saved ' + r.path); } else { toast(r.error, true); }
103
+ })
104
+ .catch(function () { toast('The Python process is no longer running', true); });
105
+ }
106
+ };
107
+
108
+ var style = document.createElement('style');
109
+ style.textContent = '.modebar-btn[data-title="Export to TikZ"] path { fill-rule: evenodd; }';
110
+ document.head.appendChild(style);
111
+
112
+ var config = __CONFIG__;
113
+ config.modeBarButtonsToAdd = (config.modeBarButtonsToAdd || []).concat([button]);
114
+ Plotly.react(gd, gd.data, gd.layout, config);
115
+
116
+ // Immediate ping: after a reload, cancels the "closing" signal sent by the previous page.
117
+ post('/ping');
118
+ setInterval(function () { post('/ping'); }, __PING_MS__);
119
+ window.addEventListener('pagehide', function () {
120
+ navigator.sendBeacon('/' + token + '/closing');
121
+ });
122
+ })();
123
+ """
124
+
125
+
126
+ def _decode_arrays(obj):
127
+ """Turn Plotly's ``{"dtype": ..., "bdata": ...}`` entries back into NumPy arrays.
128
+
129
+ Since Plotly 6, ``Figure.to_dict()``, hence the dict a renderer receives, stores NumPy
130
+ arrays in this base64 form, which the rest of tikzplotly cannot index.
131
+ """
132
+ if isinstance(obj, dict):
133
+ if "bdata" in obj and "dtype" in obj:
134
+ array = np.frombuffer(base64.b64decode(obj["bdata"]), dtype=obj["dtype"])
135
+ if "shape" in obj:
136
+ shape = obj["shape"]
137
+ if isinstance(shape, str):
138
+ shape = [int(n) for n in shape.split(",")]
139
+ array = array.reshape(shape)
140
+ return array
141
+ return {key: _decode_arrays(value) for key, value in obj.items()}
142
+ if isinstance(obj, (list, tuple)):
143
+ return [_decode_arrays(value) for value in obj]
144
+ return obj
145
+
146
+
147
+ class _LocalServer(ThreadingHTTPServer):
148
+ """HTTP server that does not look up its host name.
149
+
150
+ ``HTTPServer.server_bind`` calls ``socket.getfqdn``, a reverse DNS lookup that can take
151
+ several seconds, notably on macOS. The name is not used here.
152
+ """
153
+
154
+ def server_bind(self):
155
+ socketserver.TCPServer.server_bind(self)
156
+ self.server_name, self.server_port = self.server_address[:2] # type: ignore
157
+
158
+
159
+ def _free_path(path: Path) -> Path:
160
+ """Return ``path``, or ``stem_1.ext``, ``stem_2.ext``, ... if it already exists."""
161
+ candidate, n = path, 0
162
+ while candidate.exists():
163
+ n += 1
164
+ candidate = path.with_name(f"{path.stem}_{n}{path.suffix}")
165
+ return candidate
166
+
167
+
168
+ class TikzBrowserRenderer(ExternalRenderer): # type: ignore
169
+ """Plotly renderer opening the figure in the browser with an "Export to TikZ" button.
170
+
171
+ Parameters
172
+ ----------
173
+ output, optional
174
+ file written by the button, by default ``"figure.tex"``. If it already exists,
175
+ ``figure_1.tex``, ``figure_2.tex``, ... are used instead, so no export is overwritten.
176
+ config, optional
177
+ Plotly config dict for the page (see ``fig.show(config=...)``), by default None
178
+ **tikz_options
179
+ passed on to :func:`get_tikz_code` (``tikz_options``, ``axis_options``, ``decimate``...).
180
+ Unless ``img_name`` is given, the image of a heatmap is saved next to the ``.tex`` file,
181
+ with the same name.
182
+ """
183
+
184
+ def __init__(self, output="figure.tex", config=None, **tikz_options):
185
+ self.output = Path(output)
186
+ self.config = dict(config or {})
187
+ self.tikz_options = tikz_options
188
+ self._lock = threading.Lock()
189
+
190
+ def export(self, fig, view=None) -> Path:
191
+ """Write the TikZ code of ``fig``, as currently displayed in the page, to a free file name.
192
+
193
+ Parameters
194
+ ----------
195
+ fig
196
+ Plotly figure, or figure dict as received by a renderer
197
+ view, optional
198
+ ``{"axes": {"xaxis": [min, max] or None, ...}, "visible": [...]}`` as sent by the page
199
+
200
+ Returns
201
+ -------
202
+ path of the written file
203
+ """
204
+ view = view or {}
205
+ fig = go.Figure(_decode_arrays(fig) if isinstance(fig, dict) else fig) # copy: fig is reused
206
+ for name, axis_range in (view.get("axes") or {}).items():
207
+ fig.layout[name].range = axis_range
208
+ states = view.get("visible") or []
209
+ if len(states) == len(fig.data): # get_tikz_code exports hidden traces too
210
+ fig.data = [trace for trace, state in zip(fig.data, states) if state in (None, True)]
211
+
212
+ with self._lock: # two quick clicks must not pick the same file name
213
+ path = _free_path(self.output)
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ options = {"img_name": str(path.with_suffix(".png")), **self.tikz_options}
216
+ path.write_text(get_tikz_code(fig, **options), encoding="utf-8")
217
+ return path
218
+
219
+ def start_server(self, fig):
220
+ """Start serving the page for ``fig`` without opening a browser.
221
+
222
+ Returns
223
+ -------
224
+ ``(url, thread)``: the page's address, and the thread serving it, which ends once the
225
+ page has been closed or has not been heard from for ``IDLE_TIMEOUT`` seconds
226
+ """
227
+ if not isinstance(fig, dict):
228
+ fig = go.Figure(fig) # snapshot: later changes made by the script are not exported
229
+ fig_dict = fig if isinstance(fig, dict) else fig.to_dict()
230
+ token = secrets.token_urlsafe(24)
231
+ script = (_POST_SCRIPT.replace("__TOKEN__", token)
232
+ .replace("__CONFIG__", json.dumps(self.config))
233
+ .replace("__PING_MS__", str(PING_MS))
234
+ .replace("__ICON_PATH__", TIKZ_ICON_PATH))
235
+ html = pio.to_html(
236
+ fig_dict, config=self.config, include_plotlyjs=True,
237
+ include_mathjax="cdn", post_script=script, full_html=True, # type: ignore[reportArgumentType]
238
+ default_width="100%", default_height="100%", validate=False,
239
+ ).encode("utf-8")
240
+
241
+ renderer = self
242
+ state = {}
243
+
244
+ class Handler(BaseHTTPRequestHandler):
245
+ """Serves the page, and answers the requests sent by its script."""
246
+
247
+ def _reply(self, status, body, ctype="application/json"):
248
+ self.send_response(status)
249
+ self.send_header("Content-Type", ctype)
250
+ self.send_header("Content-Length", str(len(body)))
251
+ self.end_headers()
252
+ self.wfile.write(body)
253
+
254
+ def _host_ok(self):
255
+ # Rejects DNS rebinding: a remote site reaching this server under its own name.
256
+ server = cast(ThreadingHTTPServer, self.server)
257
+ return self.headers.get("Host") == f"127.0.0.1:{server.server_port}"
258
+
259
+ def do_GET(self): # pylint: disable=invalid-name
260
+ """Serve the page."""
261
+ if not self._host_ok() or self.path != "/":
262
+ self._reply(403, b"{}")
263
+ return
264
+ state["deadline"] = time.monotonic() + IDLE_TIMEOUT
265
+ self._reply(200, html, "text/html; charset=utf-8")
266
+
267
+ def do_POST(self): # pylint: disable=invalid-name
268
+ """Handle ``/<token>/ping``, ``/<token>/closing`` and ``/<token>/export``."""
269
+ raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
270
+ prefix = f"/{token}/"
271
+ if not self._host_ok() or not self.path.startswith(prefix):
272
+ self._reply(403, b"{}")
273
+ return
274
+ action = self.path[len(prefix):]
275
+ if action == "closing":
276
+ state["deadline"] = time.monotonic() + CLOSE_GRACE
277
+ self._reply(200, b"{}")
278
+ return
279
+ state["deadline"] = time.monotonic() + IDLE_TIMEOUT
280
+ if action == "ping":
281
+ self._reply(200, b"{}")
282
+ return
283
+ if action != "export":
284
+ self._reply(404, b"{}")
285
+ return
286
+ try:
287
+ path = renderer.export(fig, json.loads(raw or b"{}"))
288
+ result = {"ok": True, "path": str(path)}
289
+ except Exception as exc: # pylint: disable=broad-exception-caught # shown in the page
290
+ result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
291
+ self._reply(200, json.dumps(result).encode("utf-8"))
292
+
293
+ def log_message(self, format, *args): # pylint: disable=redefined-builtin
294
+ pass
295
+
296
+ server = _LocalServer(("127.0.0.1", 0), Handler)
297
+ server.timeout = 0.5
298
+ state["deadline"] = time.monotonic() + IDLE_TIMEOUT # counted from when the server is ready
299
+
300
+ def serve():
301
+ while time.monotonic() < state["deadline"]:
302
+ server.handle_request()
303
+ server.server_close()
304
+
305
+ # Not a daemon thread: the script may reach its end while the page is still open,
306
+ # and the button has to keep working until the page is closed.
307
+ thread = threading.Thread(target=serve, name="tikzplotly-browser", daemon=False)
308
+ thread.start()
309
+ return f"http://127.0.0.1:{server.server_port}/", thread
310
+
311
+ def render(self, fig_dict):
312
+ """Called by Plotly, for ``fig.show(renderer="tikzplotly")``."""
313
+ url, _ = self.start_server(fig_dict)
314
+ webbrowser.open(url)
315
+
316
+
317
+ def show(fig, output="figure.tex", config=None, **tikz_options):
318
+ """Show a figure in the web browser, with an "Export to TikZ" button in its mode bar.
319
+
320
+ Each click on the button writes the TikZ code of the figure, as currently displayed (zoom,
321
+ traces hidden from the legend), to ``output``, or to ``output_1.tex``, ``output_2.tex``, ...
322
+ if that file already exists. The Python process keeps running while the page is open,
323
+ and stops a few seconds after it is closed.
324
+
325
+ Parameters
326
+ ----------
327
+ fig
328
+ Plotly figure
329
+ output, optional
330
+ file written by the button, by default ``"figure.tex"``
331
+ config, optional
332
+ Plotly config dict for the page, by default None
333
+ **tikz_options
334
+ passed on to :func:`get_tikz_code` (``tikz_options``, ``axis_options``, ``decimate``...)
335
+ """
336
+ renderer = TikzBrowserRenderer(output, config=config, **tikz_options)
337
+ url, _ = renderer.start_server(fig)
338
+ webbrowser.open(url)
339
+
340
+
341
+ def register_renderer():
342
+ """Register the ``"tikzplotly"`` renderer with Plotly, so ``fig.show(renderer="tikzplotly")`` works."""
343
+ if ExternalRenderer is object: # pragma: no cover
344
+ return
345
+ if RENDERER_NAME not in pio.renderers:
346
+ pio.renderers[RENDERER_NAME] = TikzBrowserRenderer()
@@ -30,7 +30,7 @@ def convert_color(color):
30
30
  string from plotly color palette, can be one of the following:
31
31
  - hex string : "#______"
32
32
  - rgb string : "rgb(__, __, __)" with values between 0 and 255
33
- - rgba string : "rgba(__, __, __, __)" with values between 0 and 255 and an opacity value between 0 and 1
33
+ - rgba string : "rgba(__, __, __[, __])" with values between 0 and 255 and an opacity value between 0 and 1 (optional)
34
34
  - color name : "red", "green", "blue", "yellow", "orange", "purple", "brown", "black", "gray", "white" or any other color name from
35
35
  https://github.com/plotly/plotly.py/blob/main/templategen/utils/colors.py
36
36
 
@@ -45,20 +45,21 @@ def convert_color(color):
45
45
  if color is None:
46
46
  return None, None, None, 1
47
47
  if isinstance(color, numpy.ndarray):
48
- warn("Color from data is not supported yet. Returning the default color: blue.")
49
- return "blue", "HTML", "0000ff", 1
48
+ warn("Color from data is not supported yet. Returning no color.")
49
+ return None, None, None, 1
50
50
  if not isinstance(color, str):
51
- warn(f"Color {color} type '{color.__class__.__name__}' is not supported yet. Returning the default color: blue.")
52
- return "blue", "HTML", "0000ff", 1
51
+ warn(f"Color {color} type '{color.__class__.__name__}' is not supported yet. Returning no color.")
52
+ return None, None, None, 1
53
53
 
54
54
  if color.startswith("#"):
55
55
  return color[1:], "HTML", color[1:], 1
56
56
 
57
57
  if color.startswith("rgba"):
58
- sp = color.split("(")[1].split(",")
59
- rgb_color = sp[:-1]
60
- rgb_color = convert_color(f"rgb({rgb_color[0]},{rgb_color[1]},{rgb_color[2]})")[:-1]
61
- return rgb_color + (float(sp[-1][:-1]),)
58
+ sp = color.split("(")[1].replace(')', '').split(",")
59
+ rgb_color = [s.strip() for s in sp]
60
+ r, g, b = int(rgb_color[0]), int(rgb_color[1]), int(rgb_color[2])
61
+ opacity = float(rgb_color[3]) if len(rgb_color) == 4 else 1.0
62
+ return hashlib.sha1(f"{r}, {g}, {b}".encode('UTF-8')).hexdigest()[:10], "RGB", rgb_str(r, g, b), opacity
62
63
 
63
64
  if color.startswith("rgb"):
64
65
  color = color[4:-1].replace("[", "{").replace("]", "}")