tikzplotly 0.2.2__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.
- {tikzplotly-0.2.2/src/tikzplotly.egg-info → tikzplotly-0.2.3}/PKG-INFO +5 -5
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/README.md +3 -3
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/pyproject.toml +2 -2
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/__about__.py +1 -1
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/__init__.py +9 -1
- tikzplotly-0.2.3/src/tikzplotly/_browser.py +346 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_data_container.py +47 -10
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_save.py +8 -7
- {tikzplotly-0.2.2 → tikzplotly-0.2.3/src/tikzplotly.egg-info}/PKG-INFO +5 -5
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/SOURCES.txt +2 -0
- tikzplotly-0.2.3/tests/test_browser.py +157 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_heatmap.py +3 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_polar.py +17 -1
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_scatter.py +19 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_scatter3d.py +14 -1
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/LICENSE +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/setup.cfg +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_bars.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_heatmaps.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_histograms.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_line_charts.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_markers.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_polar.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tests/test_scatter3d.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_annotations.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_axis.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_bar.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_color.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_dash.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_data.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_heatmap.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_histogram.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_marker.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_polar.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_scatter.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_scatter3d.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_tex.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_trace_utils.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly/_utils.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/dependency_links.txt +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/requires.txt +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/top_level.txt +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_bars.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_colors.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_histograms.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_markers.py +0 -0
- {tikzplotly-0.2.2 → tikzplotly-0.2.3}/tests/test_specific.py +0 -0
- {tikzplotly-0.2.2 → 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.
|
|
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
|
|
@@ -66,7 +66,7 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
# Tikzplotly
|
|
68
68
|
|
|
69
|
-
Convert [
|
|
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.
|
|
70
70
|
|
|
71
71
|
This results in a ti*k*z code, that can be easily included into your LaTeX document.
|
|
72
72
|
This also allows to easily edit the content of the figure.
|
|
@@ -152,9 +152,9 @@ To correctly compile the document, you will need to add the following packages t
|
|
|
152
152
|
|
|
153
153
|
## Note
|
|
154
154
|
|
|
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
|
|
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.
|
|
156
156
|
You can also submit a pull request with the desired feature !
|
|
157
|
-
* Some feature can result in different output between the
|
|
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/).
|
|
158
158
|
|
|
159
159
|
|
|
160
160
|
## References
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
# Tikzplotly
|
|
17
17
|
|
|
18
|
-
Convert [
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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()
|
|
@@ -46,7 +46,7 @@ class Data:
|
|
|
46
46
|
"""Class to handle data in TikZ plots.
|
|
47
47
|
"""
|
|
48
48
|
|
|
49
|
-
def __init__(self, name, x):
|
|
49
|
+
def __init__(self, name, x, decimate: int|None=None):
|
|
50
50
|
"""Initialize a Data object.
|
|
51
51
|
|
|
52
52
|
Parameters
|
|
@@ -59,6 +59,8 @@ class Data:
|
|
|
59
59
|
self.name = name
|
|
60
60
|
self.macro_name = "\\" + replace_all_digits(name)
|
|
61
61
|
self.x = x
|
|
62
|
+
# decimate: None means keep all points, otherwise integer >= 1
|
|
63
|
+
self.decimate = decimate
|
|
62
64
|
self.y_label = []
|
|
63
65
|
self.y_data = []
|
|
64
66
|
|
|
@@ -84,7 +86,7 @@ class Data:
|
|
|
84
86
|
class Data3D:
|
|
85
87
|
"""Handle 3D data in Tikz plots
|
|
86
88
|
"""
|
|
87
|
-
def __init__(self, x, y, z, name):
|
|
89
|
+
def __init__(self, x, y, z, name, decimate: int|None=None):
|
|
88
90
|
"""Initialize the Data3D object
|
|
89
91
|
|
|
90
92
|
Parameters
|
|
@@ -101,6 +103,8 @@ class Data3D:
|
|
|
101
103
|
self.x = np.array(x)
|
|
102
104
|
self.y = np.array(y)
|
|
103
105
|
self.z = np.array(z)
|
|
106
|
+
# decimate: None means keep all points, otherwise integer >= 1
|
|
107
|
+
self.decimate = decimate
|
|
104
108
|
if name:
|
|
105
109
|
self.name = sanitize_text(name, keep_space=0)
|
|
106
110
|
else:
|
|
@@ -128,7 +132,7 @@ class DataContainer:
|
|
|
128
132
|
def __init__(self):
|
|
129
133
|
self.data = []
|
|
130
134
|
|
|
131
|
-
def add_data(self, x, y, name=None, y_label=None):
|
|
135
|
+
def add_data(self, x, y, name=None, y_label=None, decimate: int|None=None):
|
|
132
136
|
"""Add data to the container.
|
|
133
137
|
|
|
134
138
|
Parameters
|
|
@@ -141,11 +145,16 @@ class DataContainer:
|
|
|
141
145
|
name of the y data, by default None
|
|
142
146
|
name, optional
|
|
143
147
|
name of the data, by default None
|
|
148
|
+
decimate, optional
|
|
149
|
+
number of data to keep, all of them if None, by default None.
|
|
144
150
|
|
|
145
151
|
Returns
|
|
146
152
|
-------
|
|
147
153
|
tuple (macro_name, y_label), where macro_name is the name of the data in LaTeX and y_label the name of the y data in LaTeX
|
|
148
154
|
"""
|
|
155
|
+
if decimate is not None and (not isinstance(decimate, int) or decimate < 1):
|
|
156
|
+
raise ValueError("decimate must be None or an integer >= 1")
|
|
157
|
+
|
|
149
158
|
for data in self.data:
|
|
150
159
|
if len(data.x) != len(x):
|
|
151
160
|
continue
|
|
@@ -157,12 +166,12 @@ class DataContainer:
|
|
|
157
166
|
elif hasattr(are_equals, "all") and are_equals.all():
|
|
158
167
|
y_label_val = data.add_y_data(y, y_label or name)
|
|
159
168
|
return data.macro_name, treat_data(y_label_val)
|
|
160
|
-
data_to_add = Data(f"data{index_to_letters(len(self.data))}", x)
|
|
169
|
+
data_to_add = Data(f"data{index_to_letters(len(self.data))}", x, decimate=decimate)
|
|
161
170
|
y_label_val = data_to_add.add_y_data(y, y_label or name)
|
|
162
171
|
self.data.append(data_to_add)
|
|
163
172
|
return data_to_add.macro_name, treat_data(y_label_val)
|
|
164
173
|
|
|
165
|
-
def add_data3d(self, x, y, z, name=None):
|
|
174
|
+
def add_data3d(self, x, y, z, name=None, decimate: int|None=None):
|
|
166
175
|
"""Add data to the container.
|
|
167
176
|
|
|
168
177
|
Parameters
|
|
@@ -175,16 +184,21 @@ class DataContainer:
|
|
|
175
184
|
z values of the data
|
|
176
185
|
name, optional
|
|
177
186
|
name of the data, by default None
|
|
187
|
+
decimate, optional
|
|
188
|
+
number of data to keep, all of them if None, by default None.
|
|
178
189
|
|
|
179
190
|
Returns
|
|
180
191
|
-------
|
|
181
192
|
tuple (macro_name, z_name), where macro_name is the name of the data in LaTeX and z_name the name of the z data in LaTeX
|
|
182
193
|
"""
|
|
194
|
+
if decimate is not None and (not isinstance(decimate, int) or decimate < 1):
|
|
195
|
+
raise ValueError("decimate must be None or an integer >= 1")
|
|
196
|
+
|
|
183
197
|
for data in self.data:
|
|
184
198
|
if hasattr(data, "x") and hasattr(data, "y") and hasattr(data, "z"):
|
|
185
199
|
if np.array_equal(data.x, x) and np.array_equal(data.y, y) and np.array_equal(data.z, z):
|
|
186
200
|
return data.name, data.z_name
|
|
187
|
-
data_obj = Data3D(x, y, z, name)
|
|
201
|
+
data_obj = Data3D(x, y, z, name, decimate=decimate)
|
|
188
202
|
self.data.append(data_obj)
|
|
189
203
|
return data_obj.name
|
|
190
204
|
|
|
@@ -202,8 +216,20 @@ class DataContainer:
|
|
|
202
216
|
if hasattr(data, "z"):
|
|
203
217
|
export_string += "\\pgfplotstableread{\n"
|
|
204
218
|
export_string += "x y z\n"
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
n = len(data.x)
|
|
220
|
+
if getattr(data, 'decimate', None) is None:
|
|
221
|
+
indices = range(n)
|
|
222
|
+
else:
|
|
223
|
+
d = data.decimate
|
|
224
|
+
if d <= 1:
|
|
225
|
+
indices = range(n)
|
|
226
|
+
else:
|
|
227
|
+
# always include first and last
|
|
228
|
+
indices = list(range(0, n, d))
|
|
229
|
+
if indices[-1] != n-1:
|
|
230
|
+
indices.append(n-1)
|
|
231
|
+
for i in indices:
|
|
232
|
+
export_string += f"{treat_data(data.x[i])} {treat_data(data.y[i])} {treat_data(data.z[i])}\n"
|
|
207
233
|
export_string += f"}}{{\\{data.name}}}\n"
|
|
208
234
|
|
|
209
235
|
# 2D
|
|
@@ -216,8 +242,19 @@ class DataContainer:
|
|
|
216
242
|
else:
|
|
217
243
|
header += " y"
|
|
218
244
|
export_string += header + "\n"
|
|
219
|
-
|
|
220
|
-
|
|
245
|
+
n = len(data.x)
|
|
246
|
+
if getattr(data, 'decimate', None) is None:
|
|
247
|
+
indices = range(n)
|
|
248
|
+
else:
|
|
249
|
+
d = data.decimate
|
|
250
|
+
if d <= 1:
|
|
251
|
+
indices = range(n)
|
|
252
|
+
else:
|
|
253
|
+
indices = list(range(0, n, d))
|
|
254
|
+
if indices[-1] != n-1:
|
|
255
|
+
indices.append(n-1)
|
|
256
|
+
for i in indices:
|
|
257
|
+
row = [treat_data(data.x[i])]
|
|
221
258
|
for y_col in data.y_data:
|
|
222
259
|
row.append(treat_data(y_col[i]))
|
|
223
260
|
export_string += " ".join(row) + "\n"
|
|
@@ -25,10 +25,11 @@ from ._utils import sanitize_tex_text, sanitize_text
|
|
|
25
25
|
|
|
26
26
|
def get_tikz_code(
|
|
27
27
|
fig,
|
|
28
|
-
tikz_options = None,
|
|
29
|
-
axis_options = None,
|
|
30
|
-
include_disclamer = True,
|
|
31
|
-
img_name = "heatmap.png",
|
|
28
|
+
tikz_options: dict | None = None,
|
|
29
|
+
axis_options: dict | None = None,
|
|
30
|
+
include_disclamer: bool = True,
|
|
31
|
+
img_name: str = "heatmap.png",
|
|
32
|
+
decimate: int | None = None,
|
|
32
33
|
):
|
|
33
34
|
"""Get the tikz code of a figure.
|
|
34
35
|
|
|
@@ -74,7 +75,7 @@ def get_tikz_code(
|
|
|
74
75
|
if trace.y is None:
|
|
75
76
|
trace.y = list(range(len(trace.x)))
|
|
76
77
|
|
|
77
|
-
data_name_macro, y_name = data_container.add_data(trace.x, trace.y, trace.name)
|
|
78
|
+
data_name_macro, y_name = data_container.add_data(trace.x, trace.y, trace.name, decimate=decimate)
|
|
78
79
|
|
|
79
80
|
# If x is textual => symbolic x coords
|
|
80
81
|
if all(isinstance(v, str) for v in trace.x):
|
|
@@ -173,7 +174,7 @@ def get_tikz_code(
|
|
|
173
174
|
if hasattr(figure_layout.scene, "title") and getattr(figure_layout.scene.title, "text", None):
|
|
174
175
|
axis.add_option("title", f"{{{sanitize_tex_text(figure_layout.scene.title.text)}}}")
|
|
175
176
|
|
|
176
|
-
data_name_macro = data_container.add_data3d(trace.x, trace.y, trace.z, trace.name)
|
|
177
|
+
data_name_macro = data_container.add_data3d(trace.x, trace.y, trace.z, trace.name, decimate=decimate)
|
|
177
178
|
data_str.append(draw_scatter3d(data_name_macro, trace, colors_set))
|
|
178
179
|
|
|
179
180
|
if trace.name and trace['showlegend'] is not False:
|
|
@@ -228,7 +229,7 @@ def get_tikz_code(
|
|
|
228
229
|
return code
|
|
229
230
|
|
|
230
231
|
|
|
231
|
-
def save(filepath, *args, **kwargs):
|
|
232
|
+
def save(filepath: str | Path, *args, **kwargs):
|
|
232
233
|
"""Save a figure to a file or a stream.
|
|
233
234
|
|
|
234
235
|
Parameters
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tikzplotly
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -66,7 +66,7 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
# Tikzplotly
|
|
68
68
|
|
|
69
|
-
Convert [
|
|
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.
|
|
70
70
|
|
|
71
71
|
This results in a ti*k*z code, that can be easily included into your LaTeX document.
|
|
72
72
|
This also allows to easily edit the content of the figure.
|
|
@@ -152,9 +152,9 @@ To correctly compile the document, you will need to add the following packages t
|
|
|
152
152
|
|
|
153
153
|
## Note
|
|
154
154
|
|
|
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
|
|
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.
|
|
156
156
|
You can also submit a pull request with the desired feature !
|
|
157
|
-
* Some feature can result in different output between the
|
|
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/).
|
|
158
158
|
|
|
159
159
|
|
|
160
160
|
## References
|
|
@@ -13,6 +13,7 @@ src/tikzplotly/__init__.py
|
|
|
13
13
|
src/tikzplotly/_annotations.py
|
|
14
14
|
src/tikzplotly/_axis.py
|
|
15
15
|
src/tikzplotly/_bar.py
|
|
16
|
+
src/tikzplotly/_browser.py
|
|
16
17
|
src/tikzplotly/_color.py
|
|
17
18
|
src/tikzplotly/_dash.py
|
|
18
19
|
src/tikzplotly/_data.py
|
|
@@ -33,6 +34,7 @@ src/tikzplotly.egg-info/dependency_links.txt
|
|
|
33
34
|
src/tikzplotly.egg-info/requires.txt
|
|
34
35
|
src/tikzplotly.egg-info/top_level.txt
|
|
35
36
|
tests/test_bars.py
|
|
37
|
+
tests/test_browser.py
|
|
36
38
|
tests/test_colors.py
|
|
37
39
|
tests/test_heatmap.py
|
|
38
40
|
tests/test_histograms.py
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests of the "Export to TikZ" button: the local server is exercised directly, without a browser."""
|
|
2
|
+
import http.client
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import plotly.graph_objects as go
|
|
9
|
+
import plotly.io as pio
|
|
10
|
+
import pytest
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
import tikzplotly
|
|
14
|
+
from tikzplotly import _browser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(name="short_timeouts")
|
|
18
|
+
def fixture_short_timeouts(monkeypatch):
|
|
19
|
+
monkeypatch.setattr(_browser, "IDLE_TIMEOUT", 10)
|
|
20
|
+
monkeypatch.setattr(_browser, "CLOSE_GRACE", 0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_figure():
|
|
24
|
+
x = np.linspace(0, 10, 11)
|
|
25
|
+
fig = go.Figure([go.Scatter(x=x, y=x**2, name="a"), go.Scatter(x=x, y=x, name="b")])
|
|
26
|
+
fig.update_layout(title="demo")
|
|
27
|
+
return fig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Page:
|
|
31
|
+
"""Plays the part of the browser page."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, url):
|
|
34
|
+
self.port = int(url.rstrip("/").rsplit(":", 1)[1])
|
|
35
|
+
status, html = self.request("GET", "/")
|
|
36
|
+
assert status == 200
|
|
37
|
+
self.html = html.decode("utf-8")
|
|
38
|
+
token_match = re.search(r"var token = '([^']+)'", self.html)
|
|
39
|
+
assert token_match is not None
|
|
40
|
+
self.token = token_match.group(1)
|
|
41
|
+
|
|
42
|
+
def request(self, method, path, body=None, host=None):
|
|
43
|
+
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
|
44
|
+
headers = {"Host": host or f"127.0.0.1:{self.port}"}
|
|
45
|
+
conn.request(method, path, json.dumps(body or {}) if method == "POST" else None, headers)
|
|
46
|
+
response = conn.getresponse()
|
|
47
|
+
return response.status, response.read()
|
|
48
|
+
|
|
49
|
+
def post(self, action, body=None, token=None):
|
|
50
|
+
status, raw = self.request("POST", f"/{token or self.token}/{action}", body)
|
|
51
|
+
return status, json.loads(raw)
|
|
52
|
+
|
|
53
|
+
def close(self, thread):
|
|
54
|
+
self.post("closing")
|
|
55
|
+
thread.join(timeout=10)
|
|
56
|
+
assert not thread.is_alive()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_renderer_registered():
|
|
60
|
+
assert isinstance(pio.renderers["tikzplotly"], tikzplotly.TikzBrowserRenderer)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_export_button(tmp_path, short_timeouts): # pylint: disable=unused-argument
|
|
64
|
+
renderer = tikzplotly.TikzBrowserRenderer(tmp_path / "out" / "fig.tex", axis_options="width=8cm")
|
|
65
|
+
url, thread = renderer.start_server(make_figure())
|
|
66
|
+
page = Page(url)
|
|
67
|
+
assert "exportTikz" in page.html
|
|
68
|
+
|
|
69
|
+
status, result = page.post("export", {"axes": {"xaxis": None, "yaxis": None}, "visible": [None, None]})
|
|
70
|
+
assert status == 200 and result["ok"]
|
|
71
|
+
first = (tmp_path / "out" / "fig.tex").read_text(encoding="utf-8")
|
|
72
|
+
assert "width=8cm" in first and "\\addlegendentry{b}" in first and "xmin" not in first
|
|
73
|
+
|
|
74
|
+
# Zoomed in, second trace hidden from the legend: a new file, reflecting the view.
|
|
75
|
+
_, result = page.post("export", {"axes": {"xaxis": [2, 5], "yaxis": [0, 30]}, "visible": [True, "legendonly"]})
|
|
76
|
+
assert result["path"] == str(tmp_path / "out" / "fig_1.tex")
|
|
77
|
+
second = (tmp_path / "out" / "fig_1.tex").read_text(encoding="utf-8")
|
|
78
|
+
assert "xmin=2" in second and "ymax=30" in second
|
|
79
|
+
assert "\\addlegendentry{a}" in second and "\\addlegendentry{b}" not in second
|
|
80
|
+
|
|
81
|
+
page.close(thread)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_requests_rejected(tmp_path, short_timeouts): # pylint: disable=unused-argument
|
|
85
|
+
renderer = tikzplotly.TikzBrowserRenderer(tmp_path / "fig.tex")
|
|
86
|
+
url, thread = renderer.start_server(make_figure())
|
|
87
|
+
page = Page(url)
|
|
88
|
+
assert page.post("export", token="wrong")[0] == 403
|
|
89
|
+
assert page.request("POST", "/export")[0] == 403
|
|
90
|
+
assert page.request("GET", "/", host="evil.example:80")[0] == 403
|
|
91
|
+
assert page.post("unknown")[0] == 404
|
|
92
|
+
assert not list(tmp_path.iterdir())
|
|
93
|
+
page.close(thread)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_export_error_reported(tmp_path, short_timeouts): # pylint: disable=unused-argument
|
|
97
|
+
blocker = tmp_path / "blocker"
|
|
98
|
+
blocker.write_text("", encoding="utf-8") # a file where a directory is needed
|
|
99
|
+
renderer = tikzplotly.TikzBrowserRenderer(blocker / "fig.tex")
|
|
100
|
+
url, thread = renderer.start_server(make_figure())
|
|
101
|
+
page = Page(url)
|
|
102
|
+
status, result = page.post("export")
|
|
103
|
+
assert status == 200 and not result["ok"] and result["error"]
|
|
104
|
+
page.close(thread)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_idle_timeout(monkeypatch, tmp_path):
|
|
108
|
+
monkeypatch.setattr(_browser, "IDLE_TIMEOUT", 0.5)
|
|
109
|
+
_, thread = tikzplotly.TikzBrowserRenderer(tmp_path / "fig.tex").start_server(make_figure())
|
|
110
|
+
thread.join(timeout=10)
|
|
111
|
+
assert not thread.is_alive()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_figure_dict_with_binary_arrays(tmp_path):
|
|
115
|
+
"""Renderers receive ``fig.to_dict()``, where NumPy arrays are base64-encoded since Plotly 6."""
|
|
116
|
+
renderer = tikzplotly.TikzBrowserRenderer(tmp_path / "fig.tex")
|
|
117
|
+
path = renderer.export(make_figure().to_dict())
|
|
118
|
+
assert path.read_text(encoding="utf-8") == tikzplotly.get_tikz_code(make_figure(), img_name=str(tmp_path / "fig.png"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fake_png(*args, **kwargs): # pylint: disable=unused-argument
|
|
122
|
+
"""Stands for ``Figure.to_image``: a coloured block on a white background, like a heatmap."""
|
|
123
|
+
image = Image.new("RGBA", (60, 40), "white") # kaleido makes RGBA images
|
|
124
|
+
image.paste((68, 1, 84, 255), (10, 10, 40, 30))
|
|
125
|
+
buffer = io.BytesIO()
|
|
126
|
+
image.save(buffer, format="png")
|
|
127
|
+
return buffer.getvalue()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_heatmap_image_next_to_tex(tmp_path, monkeypatch):
|
|
131
|
+
# The image itself is tested in test_heatmap.py: kaleido is not needed to check file names.
|
|
132
|
+
monkeypatch.setattr(go.Figure, "to_image", fake_png)
|
|
133
|
+
renderer = tikzplotly.TikzBrowserRenderer(tmp_path / "map.tex")
|
|
134
|
+
fig = go.Figure(go.Heatmap(z=np.arange(6.0).reshape(2, 3), colorscale="Viridis"))
|
|
135
|
+
renderer.export(fig.to_dict())
|
|
136
|
+
renderer.export(fig.to_dict())
|
|
137
|
+
assert sorted(p.name for p in tmp_path.iterdir()) == ["map.png", "map.tex", "map_1.png", "map_1.tex"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_show_opens_browser(monkeypatch, tmp_path, short_timeouts): # pylint: disable=unused-argument
|
|
141
|
+
opened = []
|
|
142
|
+
monkeypatch.setattr(_browser.webbrowser, "open", opened.append)
|
|
143
|
+
started = []
|
|
144
|
+
original = _browser.TikzBrowserRenderer.start_server
|
|
145
|
+
|
|
146
|
+
def start_server(self, fig):
|
|
147
|
+
started.append(original(self, fig))
|
|
148
|
+
return started[-1]
|
|
149
|
+
|
|
150
|
+
monkeypatch.setattr(_browser.TikzBrowserRenderer, "start_server", start_server)
|
|
151
|
+
|
|
152
|
+
fig = make_figure()
|
|
153
|
+
tikzplotly.show(fig, tmp_path / "fig.tex")
|
|
154
|
+
fig.show(renderer="tikzplotly")
|
|
155
|
+
assert opened == [url for url, _ in started] and len(opened) == 2
|
|
156
|
+
for url, thread in started:
|
|
157
|
+
Page(url).close(thread)
|
|
@@ -7,6 +7,9 @@ from .helpers import assert_equality
|
|
|
7
7
|
import pathlib
|
|
8
8
|
import datetime
|
|
9
9
|
|
|
10
|
+
import sys
|
|
11
|
+
pytestmark = pytest.mark.skipif(sys.platform == "darwin", reason="kaleido is unreliable on macOS runners")
|
|
12
|
+
|
|
10
13
|
this_dir = pathlib.Path(__file__).resolve().parent
|
|
11
14
|
test_name = "test_heatmap"
|
|
12
15
|
pathlib.Path("/tmp/tikzplotly").mkdir(parents=True, exist_ok=True)
|
|
@@ -6,8 +6,12 @@ import plotly.express as px
|
|
|
6
6
|
import plotly.graph_objects as go
|
|
7
7
|
import pandas as pd
|
|
8
8
|
import pytest
|
|
9
|
+
import warnings
|
|
10
|
+
from urllib.error import URLError
|
|
9
11
|
from .helpers import assert_equality
|
|
10
12
|
|
|
13
|
+
from warnings import warn
|
|
14
|
+
|
|
11
15
|
this_dir = pathlib.Path(__file__).resolve().parent
|
|
12
16
|
test_name = "test_polar"
|
|
13
17
|
|
|
@@ -252,7 +256,13 @@ def test_radar_1():
|
|
|
252
256
|
assert_equality(plot_radar_1(), os.path.join(this_dir, test_name, test_name + "_radar_1_reference.tex"))
|
|
253
257
|
|
|
254
258
|
def test_radar_2():
|
|
255
|
-
|
|
259
|
+
try:
|
|
260
|
+
fig = plot_radar_2()
|
|
261
|
+
assert_equality(fig, os.path.join(this_dir, test_name, test_name + "_radar_2_reference.tex"))
|
|
262
|
+
except URLError as e:
|
|
263
|
+
warnings.warn(f"Offline: {e}. Skipping test_radar_2.", UserWarning)
|
|
264
|
+
pytest.skip("Offline: Could not fetch data for radar plot.")
|
|
265
|
+
|
|
256
266
|
|
|
257
267
|
def test_polar_categorial_angular():
|
|
258
268
|
assert_equality(fig_polar_categorial_angular(), os.path.join(this_dir, test_name, test_name + "_categorical_angular_reference.tex"))
|
|
@@ -273,6 +283,12 @@ def test_polar_range_2():
|
|
|
273
283
|
assert_equality(fig_polar_range_2(), os.path.join(this_dir, test_name, test_name + "_radar_range_2_reference.tex"))
|
|
274
284
|
|
|
275
285
|
def test_polar_1():
|
|
286
|
+
try:
|
|
287
|
+
fig = fig_polar_1()
|
|
288
|
+
assert_equality(fig, os.path.join(this_dir, test_name, test_name + "_1_reference.tex"))
|
|
289
|
+
except URLError as e:
|
|
290
|
+
warnings.warn(f"Offline: {e}. Skipping test_polar_1.", UserWarning)
|
|
291
|
+
pytest.skip("Offline: Could not fetch data for radar plot.")
|
|
276
292
|
assert_equality(fig_polar_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex"))
|
|
277
293
|
|
|
278
294
|
def test_polar_matplotlib():
|
|
@@ -211,6 +211,11 @@ def plot_10():
|
|
|
211
211
|
fig.update_yaxes(autorange="reversed")
|
|
212
212
|
return fig
|
|
213
213
|
|
|
214
|
+
def plot_decimate():
|
|
215
|
+
x = np.linspace(-6, 6, 103)
|
|
216
|
+
y = np.sin(x)
|
|
217
|
+
fig = px.scatter(x=x, y=y)
|
|
218
|
+
return fig
|
|
214
219
|
|
|
215
220
|
def test_1():
|
|
216
221
|
assert_equality(plot_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex"))
|
|
@@ -249,3 +254,17 @@ def test_9():
|
|
|
249
254
|
|
|
250
255
|
def test_10():
|
|
251
256
|
assert_equality(plot_10(), os.path.join(this_dir, test_name, test_name + "_10_reference.tex"))
|
|
257
|
+
|
|
258
|
+
@pytest.mark.parametrize("d", [10, 1, None])
|
|
259
|
+
def test_decimate(d):
|
|
260
|
+
if not d:
|
|
261
|
+
d_path = 1
|
|
262
|
+
else:
|
|
263
|
+
d_path = d
|
|
264
|
+
assert_equality(plot_decimate(), os.path.join(this_dir, test_name, test_name + f"_decimate_{d_path}_reference.tex"), decimate=d)
|
|
265
|
+
|
|
266
|
+
def test_failing_decimate():
|
|
267
|
+
try:
|
|
268
|
+
assert_equality(plot_decimate(), os.path.join(this_dir, test_name, test_name + f"_decimate_1_reference.tex"), decimate=-1)
|
|
269
|
+
except ValueError as e:
|
|
270
|
+
assert e.__str__() == "decimate must be None or an integer >= 1"
|
|
@@ -76,7 +76,6 @@ def plot_scatter_3d_view():
|
|
|
76
76
|
)
|
|
77
77
|
return fig
|
|
78
78
|
|
|
79
|
-
|
|
80
79
|
def plot_scatter_3d_empty():
|
|
81
80
|
return go.Figure(data=[go.Scatter3d(
|
|
82
81
|
x=None,
|
|
@@ -84,6 +83,7 @@ def plot_scatter_3d_empty():
|
|
|
84
83
|
z=None,
|
|
85
84
|
)])
|
|
86
85
|
|
|
86
|
+
|
|
87
87
|
def test_scatter_3d_1():
|
|
88
88
|
assert_equality(plot_scatter_3d_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex"))
|
|
89
89
|
|
|
@@ -106,3 +106,16 @@ def test_scatter_3d_view():
|
|
|
106
106
|
def test_scatter_3d_empty():
|
|
107
107
|
with pytest.warns(UserWarning, match="Adding empty 3D trace."):
|
|
108
108
|
assert_equality(plot_scatter_3d_empty(), os.path.join(this_dir, test_name, test_name + "_empty_reference.tex"))
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("d", [6, 1, None])
|
|
111
|
+
def test_failing_decimate(d):
|
|
112
|
+
suffix = "6_" if d == 6 else ""
|
|
113
|
+
assert_equality(plot_scatter_3d_3(), os.path.join(this_dir, test_name, test_name + f"_3_{suffix}reference.tex"), decimate=d)
|
|
114
|
+
|
|
115
|
+
def test_scatter_3d_decimate():
|
|
116
|
+
try:
|
|
117
|
+
assert_equality(plot_scatter_3d_3(), os.path.join(this_dir, test_name, test_name + f"_3_reference.tex"), decimate=-1)
|
|
118
|
+
except ValueError as e:
|
|
119
|
+
assert e.__str__() == "decimate must be None or an integer >= 1"
|
|
120
|
+
|
|
121
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|