plotly-print 0.1.0__py3-none-any.whl
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.
- plotly_print/__init__.py +20 -0
- plotly_print/core.py +76 -0
- plotly_print/engine.py +172 -0
- plotly_print/fonts.py +143 -0
- plotly_print/js/micro_dom.js +1195 -0
- plotly_print/js/plotly-cartesian.min.js +149 -0
- plotly_print/plotly_hook.py +95 -0
- plotly_print/raster.py +186 -0
- plotly_print-0.1.0.dist-info/METADATA +66 -0
- plotly_print-0.1.0.dist-info/RECORD +13 -0
- plotly_print-0.1.0.dist-info/WHEEL +4 -0
- plotly_print-0.1.0.dist-info/licenses/LICENSE +22 -0
- plotly_print-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +40 -0
plotly_print/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from plotly_print.core import to_image, to_svg, write_image
|
|
4
|
+
from plotly_print.plotly_hook import patch_plotly, register, unpatch_plotly, unregister
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version('plotly-print')
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = '0.1.0'
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'to_svg',
|
|
13
|
+
'to_image',
|
|
14
|
+
'write_image',
|
|
15
|
+
'register',
|
|
16
|
+
'unregister',
|
|
17
|
+
'patch_plotly',
|
|
18
|
+
'unpatch_plotly',
|
|
19
|
+
'__version__',
|
|
20
|
+
]
|
plotly_print/core.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, BinaryIO
|
|
5
|
+
|
|
6
|
+
from plotly_print.engine import get_engine
|
|
7
|
+
from plotly_print.raster import svg_to_jpeg, svg_to_png, svg_to_webp, write_raster
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _extract_figure_spec(fig: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
11
|
+
if hasattr(fig, 'to_dict'):
|
|
12
|
+
d = fig.to_dict()
|
|
13
|
+
elif isinstance(fig, dict):
|
|
14
|
+
d = fig
|
|
15
|
+
else:
|
|
16
|
+
raise TypeError(f'Expected plotly.graph_objects.Figure or dict, got {type(fig).__name__}')
|
|
17
|
+
|
|
18
|
+
return d.get('data', []), d.get('layout', {})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_svg(
|
|
22
|
+
fig: Any,
|
|
23
|
+
width: int | None = None,
|
|
24
|
+
height: int | None = None,
|
|
25
|
+
) -> str:
|
|
26
|
+
data, layout = _extract_figure_spec(fig)
|
|
27
|
+
return get_engine().render_svg(data, layout, width=width, height=height)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_image(
|
|
31
|
+
fig: Any,
|
|
32
|
+
format: str = 'png',
|
|
33
|
+
width: int | None = None,
|
|
34
|
+
height: int | None = None,
|
|
35
|
+
scale: float = 1.0,
|
|
36
|
+
) -> bytes:
|
|
37
|
+
fmt = format.lower().strip()
|
|
38
|
+
svg = to_svg(fig, width=width, height=height)
|
|
39
|
+
|
|
40
|
+
if fmt == 'svg':
|
|
41
|
+
return svg.encode('utf-8')
|
|
42
|
+
if fmt == 'png':
|
|
43
|
+
return svg_to_png(svg, width=width, height=height, scale=scale)
|
|
44
|
+
if fmt in ('jpg', 'jpeg'):
|
|
45
|
+
return svg_to_jpeg(svg, width=width, height=height, scale=scale)
|
|
46
|
+
if fmt == 'webp':
|
|
47
|
+
return svg_to_webp(svg, width=width, height=height, scale=scale)
|
|
48
|
+
raise ValueError(f"Unsupported format: {format}. Supported: 'svg', 'png', 'jpeg', 'webp'")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_image(
|
|
52
|
+
fig: Any,
|
|
53
|
+
file: str | os.PathLike[str] | BinaryIO,
|
|
54
|
+
format: str | None = None,
|
|
55
|
+
scale: float = 1.0,
|
|
56
|
+
width: int | None = None,
|
|
57
|
+
height: int | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if format is None:
|
|
60
|
+
if isinstance(file, (str, os.PathLike)):
|
|
61
|
+
ext = os.path.splitext(str(file))[1].lower().lstrip('.')
|
|
62
|
+
format = ext or 'png'
|
|
63
|
+
else:
|
|
64
|
+
format = 'png'
|
|
65
|
+
|
|
66
|
+
fmt = format.lower().strip()
|
|
67
|
+
svg_str = to_svg(fig, width=width, height=height)
|
|
68
|
+
if fmt == 'svg':
|
|
69
|
+
if isinstance(file, (str, os.PathLike)):
|
|
70
|
+
with open(file, 'w', encoding='utf-8') as f:
|
|
71
|
+
f.write(svg_str)
|
|
72
|
+
else:
|
|
73
|
+
file.write(svg_str.encode('utf-8'))
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
write_raster(svg_str, file, format=fmt, width=width, height=height, scale=scale)
|
plotly_print/engine.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import quickjs
|
|
12
|
+
from PIL import Image, ImageColor, ImageDraw
|
|
13
|
+
from plotly.utils import PlotlyJSONEncoder
|
|
14
|
+
|
|
15
|
+
from plotly_print.fonts import get_default_font_manager
|
|
16
|
+
|
|
17
|
+
_JS_DIR = Path(__file__).resolve().parent / 'js'
|
|
18
|
+
_MICRO_DOM_PATH = _JS_DIR / 'micro_dom.js'
|
|
19
|
+
# Official cartesian partial: full plotly.min.js from the plotly package does not load in QuickJS.
|
|
20
|
+
_PLOTLY_JS_PATH = _JS_DIR / 'plotly-cartesian.min.js'
|
|
21
|
+
|
|
22
|
+
_ENGINE_THREAD_LOCAL = threading.local()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_color(c: str) -> tuple[int, int, int, int] | tuple[int, int, int]:
|
|
26
|
+
if not c:
|
|
27
|
+
return (0, 0, 0, 0)
|
|
28
|
+
c = c.strip()
|
|
29
|
+
m = re.match(r'rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)', c, re.I)
|
|
30
|
+
if m:
|
|
31
|
+
r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
32
|
+
a = int(float(m.group(4)) * 255) if m.group(4) is not None else 255
|
|
33
|
+
return (r, g, b, a)
|
|
34
|
+
try:
|
|
35
|
+
return ImageColor.getrgb(c)
|
|
36
|
+
except Exception:
|
|
37
|
+
return (0, 0, 0, 255)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode_canvas_to_png(width: int, height: int, commands_json: str) -> str:
|
|
41
|
+
w = max(1, int(width))
|
|
42
|
+
h = max(1, int(height))
|
|
43
|
+
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
|
44
|
+
draw = ImageDraw.Draw(img)
|
|
45
|
+
try:
|
|
46
|
+
commands = json.loads(commands_json)
|
|
47
|
+
except Exception:
|
|
48
|
+
commands = []
|
|
49
|
+
|
|
50
|
+
for cmd in commands:
|
|
51
|
+
op = cmd.get('op')
|
|
52
|
+
if op == 'fillRect':
|
|
53
|
+
x = cmd.get('x', 0)
|
|
54
|
+
y = cmd.get('y', 0)
|
|
55
|
+
cw = cmd.get('w', 0)
|
|
56
|
+
ch = cmd.get('h', 0)
|
|
57
|
+
fill = _parse_color(cmd.get('fill', '#000000'))
|
|
58
|
+
draw.rectangle([x, y, x + cw, y + ch], fill=fill)
|
|
59
|
+
elif op == 'clearRect':
|
|
60
|
+
x = cmd.get('x', 0)
|
|
61
|
+
y = cmd.get('y', 0)
|
|
62
|
+
cw = cmd.get('w', 0)
|
|
63
|
+
ch = cmd.get('h', 0)
|
|
64
|
+
draw.rectangle([x, y, x + cw, y + ch], fill=(0, 0, 0, 0))
|
|
65
|
+
|
|
66
|
+
buf = io.BytesIO()
|
|
67
|
+
img.save(buf, format='PNG')
|
|
68
|
+
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
|
|
69
|
+
return f'data:image/png;base64,{b64}'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PrintEngine:
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self.font_manager = get_default_font_manager()
|
|
75
|
+
self.context = quickjs.Context()
|
|
76
|
+
self.context.set_max_stack_size(10 * 1024 * 1024)
|
|
77
|
+
|
|
78
|
+
self.context.add_callable('_measureTextRaw', self.font_manager.measure_raw)
|
|
79
|
+
self.context.add_callable('_encodeCanvasToPNG', _encode_canvas_to_png)
|
|
80
|
+
|
|
81
|
+
if not _PLOTLY_JS_PATH.is_file():
|
|
82
|
+
raise FileNotFoundError(f'plotly.js bundle not found at {_PLOTLY_JS_PATH}')
|
|
83
|
+
|
|
84
|
+
self.context.eval(_MICRO_DOM_PATH.read_text(encoding='utf-8'))
|
|
85
|
+
self.context.eval(_PLOTLY_JS_PATH.read_text(encoding='utf-8'))
|
|
86
|
+
|
|
87
|
+
self.context.eval("""
|
|
88
|
+
function plotlyPrintRender(dataJson, layoutJson) {
|
|
89
|
+
var data = JSON.parse(dataJson);
|
|
90
|
+
var layout = JSON.parse(layoutJson);
|
|
91
|
+
|
|
92
|
+
var w = layout.width || 600;
|
|
93
|
+
var h = layout.height || 400;
|
|
94
|
+
|
|
95
|
+
var container = document.createElement('div');
|
|
96
|
+
container.id = 'plotly-print-' + Math.random().toString(36).slice(2);
|
|
97
|
+
container.clientWidth = w;
|
|
98
|
+
container.clientHeight = h;
|
|
99
|
+
container.offsetWidth = w;
|
|
100
|
+
container.offsetHeight = h;
|
|
101
|
+
container.getBoundingClientRect = function() {
|
|
102
|
+
return { left: 0, top: 0, right: w, bottom: h, width: w, height: h };
|
|
103
|
+
};
|
|
104
|
+
document.body.appendChild(container);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
Plotly.newPlot(container, data, layout, { staticPlot: true });
|
|
108
|
+
var res = null;
|
|
109
|
+
if (Plotly.Snapshot && typeof Plotly.Snapshot.toSVG === 'function') {
|
|
110
|
+
try {
|
|
111
|
+
res = Plotly.Snapshot.toSVG(container, 'svg');
|
|
112
|
+
} catch(snapErr) {
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!res) {
|
|
116
|
+
var svgs = container.querySelectorAll('svg');
|
|
117
|
+
if (svgs.length === 0) {
|
|
118
|
+
document.body.removeChild(container);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
var mainSvg = svgs[0];
|
|
122
|
+
for (var s = 1; s < svgs.length; s++) {
|
|
123
|
+
var otherSvg = svgs[s];
|
|
124
|
+
var kids = otherSvg.childNodes.slice();
|
|
125
|
+
for (var k = 0; k < kids.length; k++) {
|
|
126
|
+
var kid = kids[k];
|
|
127
|
+
if (kid.nodeType === 1 && kid.classList && kid.classList.contains('hoverlayer')) continue;
|
|
128
|
+
mainSvg.appendChild(kid);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
res = mainSvg.outerHTML;
|
|
132
|
+
}
|
|
133
|
+
if (res) {
|
|
134
|
+
res = res.replace(/TOBESTRIPPED/g, "'");
|
|
135
|
+
}
|
|
136
|
+
document.body.removeChild(container);
|
|
137
|
+
return res;
|
|
138
|
+
} catch(e) {
|
|
139
|
+
try { document.body.removeChild(container); } catch(ignore) {}
|
|
140
|
+
throw new Error("Plotly render failed: " + e.message + "\\n" + e.stack);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
def render_svg(
|
|
146
|
+
self,
|
|
147
|
+
data: list[dict[str, Any]],
|
|
148
|
+
layout: dict[str, Any],
|
|
149
|
+
width: int | None = None,
|
|
150
|
+
height: int | None = None,
|
|
151
|
+
) -> str:
|
|
152
|
+
layout_copy = dict(layout) if layout else {}
|
|
153
|
+
if width is not None:
|
|
154
|
+
layout_copy['width'] = width
|
|
155
|
+
if height is not None:
|
|
156
|
+
layout_copy['height'] = height
|
|
157
|
+
|
|
158
|
+
data_json = json.dumps(data, cls=PlotlyJSONEncoder)
|
|
159
|
+
layout_json = json.dumps(layout_copy, cls=PlotlyJSONEncoder)
|
|
160
|
+
|
|
161
|
+
self.context.eval(f'var __plotly_print_data = {json.dumps(data_json)};')
|
|
162
|
+
self.context.eval(f'var __plotly_print_layout = {json.dumps(layout_json)};')
|
|
163
|
+
svg = self.context.eval('plotlyPrintRender(__plotly_print_data, __plotly_print_layout);')
|
|
164
|
+
if not svg:
|
|
165
|
+
raise RuntimeError('plotly-print rendering failed: No SVG element was generated by Plotly.')
|
|
166
|
+
return svg
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_engine() -> PrintEngine:
|
|
170
|
+
if not hasattr(_ENGINE_THREAD_LOCAL, 'engine'):
|
|
171
|
+
_ENGINE_THREAD_LOCAL.engine = PrintEngine()
|
|
172
|
+
return _ENGINE_THREAD_LOCAL.engine
|
plotly_print/fonts.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import ImageFont
|
|
6
|
+
|
|
7
|
+
CANDIDATE_FONTS = [
|
|
8
|
+
'/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
9
|
+
'/System/Library/Fonts/Helvetica.ttc',
|
|
10
|
+
'/Library/Fonts/Arial.ttf',
|
|
11
|
+
'/System/Library/Fonts/SFNSText.ttf',
|
|
12
|
+
'/System/Library/Fonts/Supplemental/Verdana.ttf',
|
|
13
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
14
|
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
15
|
+
'/usr/share/fonts/truetype/freefont/FreeSans.ttf',
|
|
16
|
+
'C:\\Windows\\Fonts\\arial.ttf',
|
|
17
|
+
'C:\\Windows\\Fonts\\segoeui.ttf',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FontManager:
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._font_cache: dict[tuple[str, int, str], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
|
|
24
|
+
self._measurement_cache: dict[tuple[str, str, int, str], tuple[float, float, float, float]] = {}
|
|
25
|
+
self._default_system_font_path = self._find_first_available_font()
|
|
26
|
+
|
|
27
|
+
def _find_first_available_font(self) -> str | None:
|
|
28
|
+
for path in CANDIDATE_FONTS:
|
|
29
|
+
if Path(path).exists():
|
|
30
|
+
return path
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def get_font(self, family: str, size: float, weight: str = '') -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
34
|
+
int_size = max(1, round(size))
|
|
35
|
+
cache_key = (family.lower().strip(), int_size, weight.lower().strip())
|
|
36
|
+
if cache_key in self._font_cache:
|
|
37
|
+
return self._font_cache[cache_key]
|
|
38
|
+
|
|
39
|
+
font = None
|
|
40
|
+
names = [r.strip().strip('"\'').lower() for r in family.split(',')]
|
|
41
|
+
# Prefer Arial/Helvetica when Plotly's default stack lists verdana with sans-serif.
|
|
42
|
+
if 'verdana' in names and ('arial' in names or 'sans-serif' in names or 'open sans' in names):
|
|
43
|
+
names.remove('verdana')
|
|
44
|
+
names.append('verdana')
|
|
45
|
+
|
|
46
|
+
for clean_name in names:
|
|
47
|
+
if not clean_name or clean_name in ('sans-serif', 'serif', 'monospace'):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
for test_name in (clean_name.title(), clean_name.capitalize(), clean_name):
|
|
51
|
+
try:
|
|
52
|
+
font = ImageFont.truetype(test_name, size=int_size)
|
|
53
|
+
if font:
|
|
54
|
+
break
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
if font:
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
for font_path in CANDIDATE_FONTS:
|
|
61
|
+
base = Path(font_path).name.lower()
|
|
62
|
+
if base.startswith(clean_name) and Path(font_path).exists():
|
|
63
|
+
try:
|
|
64
|
+
font = ImageFont.truetype(font_path, size=int_size)
|
|
65
|
+
if font:
|
|
66
|
+
break
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
if font:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if font is None and self._default_system_font_path:
|
|
73
|
+
try:
|
|
74
|
+
font = ImageFont.truetype(self._default_system_font_path, size=int_size)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if font is None:
|
|
79
|
+
try:
|
|
80
|
+
font = ImageFont.load_default(size=int_size)
|
|
81
|
+
except TypeError:
|
|
82
|
+
font = ImageFont.load_default()
|
|
83
|
+
|
|
84
|
+
self._font_cache[cache_key] = font
|
|
85
|
+
return font
|
|
86
|
+
|
|
87
|
+
def measure(self, text: str, family: str, size: float, weight: str = '') -> tuple[float, float, float, float]:
|
|
88
|
+
int_size = max(1, round(size))
|
|
89
|
+
cache_key = (text, family.lower().strip(), int_size, weight.lower().strip())
|
|
90
|
+
if cache_key in self._measurement_cache:
|
|
91
|
+
return self._measurement_cache[cache_key]
|
|
92
|
+
|
|
93
|
+
if not text:
|
|
94
|
+
res = (0.0, float(int_size), float(int_size), 0.0)
|
|
95
|
+
self._measurement_cache[cache_key] = res
|
|
96
|
+
return res
|
|
97
|
+
|
|
98
|
+
font = self.get_font(family, size, weight)
|
|
99
|
+
try:
|
|
100
|
+
length = float(font.getlength(text))
|
|
101
|
+
except Exception:
|
|
102
|
+
length = float(len(text) * int_size * 0.6)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
bbox = font.getbbox(text)
|
|
106
|
+
try:
|
|
107
|
+
metrics = font.getmetrics()
|
|
108
|
+
ascent_val = float(metrics[0])
|
|
109
|
+
descent_val = float(metrics[1])
|
|
110
|
+
h = max(ascent_val + descent_val, float(int_size * 1.2))
|
|
111
|
+
ascent = ascent_val if ascent_val > 0 else float(int_size * 0.8)
|
|
112
|
+
descent = descent_val if descent_val > 0 else float(int_size * 0.2)
|
|
113
|
+
except Exception:
|
|
114
|
+
h = float(int_size * 1.2)
|
|
115
|
+
ascent = float(int_size * 0.8)
|
|
116
|
+
descent = float(int_size * 0.2)
|
|
117
|
+
|
|
118
|
+
if bbox:
|
|
119
|
+
w = max(length, float(bbox[2] - bbox[0]))
|
|
120
|
+
else:
|
|
121
|
+
w = length
|
|
122
|
+
except Exception:
|
|
123
|
+
w, h = length, float(int_size * 1.2)
|
|
124
|
+
ascent, descent = float(int_size * 0.8), float(int_size * 0.2)
|
|
125
|
+
|
|
126
|
+
res = (round(w, 2), round(h, 2), round(ascent, 2), round(descent, 2))
|
|
127
|
+
self._measurement_cache[cache_key] = res
|
|
128
|
+
return res
|
|
129
|
+
|
|
130
|
+
def measure_raw(self, text: str, family: str, size_str: str, weight: str = '') -> str:
|
|
131
|
+
try:
|
|
132
|
+
size = float(size_str)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
size = 12.0
|
|
135
|
+
w, h, a, d = self.measure(text, family, size, weight)
|
|
136
|
+
return f'{w},{h},{a},{d}'
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_DEFAULT_FONT_MANAGER = FontManager()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_default_font_manager() -> FontManager:
|
|
143
|
+
return _DEFAULT_FONT_MANAGER
|