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.
@@ -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