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.
- {tikzplotly-0.2.1/src/tikzplotly.egg-info → tikzplotly-0.2.3}/PKG-INFO +6 -5
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/README.md +3 -3
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/pyproject.toml +11 -3
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/__about__.py +1 -1
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/__init__.py +9 -1
- tikzplotly-0.2.3/src/tikzplotly/_browser.py +346 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_color.py +10 -9
- tikzplotly-0.2.1/src/tikzplotly/_dataContainer.py → tikzplotly-0.2.3/src/tikzplotly/_data_container.py +48 -11
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_heatmap.py +3 -2
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_histogram.py +27 -27
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_polar.py +1 -1
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_save.py +11 -10
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_scatter.py +6 -36
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_scatter3d.py +6 -35
- tikzplotly-0.2.3/src/tikzplotly/_trace_utils.py +70 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_utils.py +2 -10
- {tikzplotly-0.2.1 → tikzplotly-0.2.3/src/tikzplotly.egg-info}/PKG-INFO +6 -5
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/SOURCES.txt +5 -1
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/requires.txt +1 -0
- tikzplotly-0.2.3/tests/test_browser.py +157 -0
- tikzplotly-0.2.3/tests/test_colors.py +53 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_heatmap.py +12 -5
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_histograms.py +10 -3
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_markers.py +7 -1
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_polar.py +20 -2
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_scatter.py +27 -6
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_scatter3d.py +24 -3
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_specific.py +16 -12
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/LICENSE +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/setup.cfg +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_bars.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_heatmaps.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_histograms.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_line_charts.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_markers.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_polar.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tests/test_scatter3d.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_annotations.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_axis.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_bar.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_dash.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_data.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_marker.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly/_tex.py +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/dependency_links.txt +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/src/tikzplotly.egg-info/top_level.txt +0 -0
- {tikzplotly-0.2.1 → tikzplotly-0.2.3}/tests/test_bars.py +0 -0
- {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.
|
|
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 [
|
|
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
|
|
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
|
|
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 [
|
|
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",
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
49
|
-
return
|
|
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
|
|
52
|
-
return
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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("]", "}")
|