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,95 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, BinaryIO
4
+
5
+ import plotly_print.core as core
6
+
7
+ _ORIGINAL_TO_IMAGE = None
8
+ _ORIGINAL_WRITE_IMAGE = None
9
+ _IS_PATCHED = False
10
+
11
+
12
+ def _patched_to_image(
13
+ fig: Any,
14
+ format: str | None = None,
15
+ width: int | None = None,
16
+ height: int | None = None,
17
+ scale: float | None = None,
18
+ validate: bool = True,
19
+ **kwargs: Any,
20
+ ) -> bytes:
21
+ return core.to_image(
22
+ fig,
23
+ format=format or 'png',
24
+ width=width,
25
+ height=height,
26
+ scale=scale or 1.0,
27
+ )
28
+
29
+
30
+ def _patched_write_image(
31
+ fig: Any,
32
+ file: str | BinaryIO,
33
+ format: str | None = None,
34
+ scale: float | None = None,
35
+ width: int | None = None,
36
+ height: int | None = None,
37
+ validate: bool = True,
38
+ **kwargs: Any,
39
+ ) -> None:
40
+ core.write_image(
41
+ fig,
42
+ file=file,
43
+ format=format,
44
+ scale=scale or 1.0,
45
+ width=width,
46
+ height=height,
47
+ )
48
+
49
+
50
+ def register() -> None:
51
+ global _ORIGINAL_TO_IMAGE, _ORIGINAL_WRITE_IMAGE, _IS_PATCHED
52
+ if _IS_PATCHED:
53
+ return
54
+
55
+ try:
56
+ import plotly.graph_objects as go
57
+ import plotly.io as pio
58
+
59
+ _ORIGINAL_TO_IMAGE = pio.to_image
60
+ _ORIGINAL_WRITE_IMAGE = pio.write_image
61
+
62
+ pio.to_image = _patched_to_image
63
+ pio.write_image = _patched_write_image
64
+ go.Figure.to_image = _patched_to_image
65
+ go.Figure.write_image = _patched_write_image
66
+
67
+ _IS_PATCHED = True
68
+ except ImportError:
69
+ pass
70
+
71
+
72
+ def unregister() -> None:
73
+ global _ORIGINAL_TO_IMAGE, _ORIGINAL_WRITE_IMAGE, _IS_PATCHED
74
+ if not _IS_PATCHED:
75
+ return
76
+
77
+ try:
78
+ import plotly.graph_objects as go
79
+ import plotly.io as pio
80
+
81
+ if _ORIGINAL_TO_IMAGE:
82
+ pio.to_image = _ORIGINAL_TO_IMAGE
83
+ go.Figure.to_image = _ORIGINAL_TO_IMAGE
84
+
85
+ if _ORIGINAL_WRITE_IMAGE:
86
+ pio.write_image = _ORIGINAL_WRITE_IMAGE
87
+ go.Figure.write_image = _ORIGINAL_WRITE_IMAGE
88
+
89
+ _IS_PATCHED = False
90
+ except ImportError:
91
+ pass
92
+
93
+
94
+ patch_plotly = register
95
+ unpatch_plotly = unregister
plotly_print/raster.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import re
5
+ from pathlib import Path
6
+ from typing import BinaryIO
7
+
8
+ import resvg_py
9
+ from PIL import Image
10
+
11
+ SYSTEM_FONT_DIRS = [
12
+ '/System/Library/Fonts/Supplemental',
13
+ '/Library/Fonts',
14
+ '/System/Library/Fonts',
15
+ '/usr/share/fonts',
16
+ '/usr/local/share/fonts',
17
+ ]
18
+ EXISTING_FONT_DIRS = [d for d in SYSTEM_FONT_DIRS if Path(d).is_dir()]
19
+
20
+ COMMON_FONT_NAMES = [
21
+ 'verdana',
22
+ 'arial',
23
+ 'tahoma',
24
+ 'trebuchet ms',
25
+ 'helvetica',
26
+ 'times new roman',
27
+ 'courier new',
28
+ 'georgia',
29
+ ]
30
+
31
+ _SVG_ROOT_RE = re.compile(r'<svg\b[^>]*>', re.IGNORECASE)
32
+ _ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*("([^"]*)"|\'([^\']*)\')')
33
+ _LENGTH_RE = re.compile(r'^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*[a-z%]*$', re.IGNORECASE)
34
+
35
+
36
+ def _normalize_svg_fonts(svg: str) -> str:
37
+ def _cap(m: re.Match[str]) -> str:
38
+ val = m.group(0)
39
+ for name in COMMON_FONT_NAMES:
40
+ val = re.sub(r'\b' + re.escape(name) + r'\b', name.title(), val, flags=re.IGNORECASE)
41
+ return val
42
+
43
+ return re.sub(r'font-family:\s*[^;\"}]+', _cap, svg)
44
+
45
+
46
+ def _parse_length(value: str | None) -> float | None:
47
+ if value is None:
48
+ return None
49
+ m = _LENGTH_RE.match(value.strip())
50
+ if not m:
51
+ return None
52
+ return float(m.group(1))
53
+
54
+
55
+ def _parse_svg_layout_size(svg: str) -> tuple[float, float]:
56
+ root = _SVG_ROOT_RE.search(svg)
57
+ if not root:
58
+ raise ValueError('SVG root element not found')
59
+
60
+ attrs = {
61
+ m.group(1).lower(): (m.group(3) if m.group(3) is not None else m.group(4))
62
+ for m in _ATTR_RE.finditer(root.group(0))
63
+ }
64
+
65
+ width = _parse_length(attrs.get('width'))
66
+ height = _parse_length(attrs.get('height'))
67
+
68
+ if (width is None or height is None) and 'viewbox' in attrs:
69
+ parts = attrs['viewbox'].replace(',', ' ').split()
70
+ if len(parts) == 4:
71
+ if width is None:
72
+ width = float(parts[2])
73
+ if height is None:
74
+ height = float(parts[3])
75
+
76
+ if width is None or height is None:
77
+ raise ValueError('Could not determine SVG layout width/height')
78
+ return width, height
79
+
80
+
81
+ def _prepare_svg_for_raster(
82
+ svg: str,
83
+ width: int | None = None,
84
+ height: int | None = None,
85
+ scale: float = 1.0,
86
+ ) -> str:
87
+ layout_w, layout_h = _parse_svg_layout_size(svg)
88
+ if width is not None:
89
+ layout_w = float(width)
90
+ if height is not None:
91
+ layout_h = float(height)
92
+
93
+ out_w = max(1, int(round(layout_w * scale)))
94
+ out_h = max(1, int(round(layout_h * scale)))
95
+
96
+ root = _SVG_ROOT_RE.search(svg)
97
+ if not root:
98
+ raise ValueError('SVG root element not found')
99
+
100
+ tag = root.group(0)
101
+ existing = {m.group(1).lower() for m in _ATTR_RE.finditer(tag)}
102
+
103
+ def _set_attr(tag_str: str, name: str, value: str) -> str:
104
+ pattern = rf'({re.escape(name)}\s*=\s*)("([^"]*)"|\'([^\']*)\')'
105
+ if re.search(pattern, tag_str, flags=re.IGNORECASE):
106
+ return re.sub(pattern, rf'\1"{value}"', tag_str, count=1, flags=re.IGNORECASE)
107
+ return re.sub(r'\s*>$', f' {name}="{value}">', tag_str, count=1)
108
+
109
+ new_tag = tag
110
+ new_tag = _set_attr(new_tag, 'width', str(out_w))
111
+ new_tag = _set_attr(new_tag, 'height', str(out_h))
112
+ if 'viewbox' not in existing:
113
+ vb_w = int(layout_w) if layout_w == int(layout_w) else layout_w
114
+ vb_h = int(layout_h) if layout_h == int(layout_h) else layout_h
115
+ new_tag = _set_attr(new_tag, 'viewBox', f'0 0 {vb_w} {vb_h}')
116
+
117
+ return svg[: root.start()] + new_tag + svg[root.end() :]
118
+
119
+
120
+ def svg_to_png(
121
+ svg: str,
122
+ width: int | None = None,
123
+ height: int | None = None,
124
+ scale: float = 1.0,
125
+ ) -> bytes:
126
+ normalized_svg = _normalize_svg_fonts(svg)
127
+ prepared = _prepare_svg_for_raster(normalized_svg, width=width, height=height, scale=scale)
128
+ if EXISTING_FONT_DIRS:
129
+ return resvg_py.svg_to_bytes(prepared, font_dirs=EXISTING_FONT_DIRS)
130
+ return resvg_py.svg_to_bytes(prepared)
131
+
132
+
133
+ def svg_to_jpeg(
134
+ svg: str,
135
+ width: int | None = None,
136
+ height: int | None = None,
137
+ scale: float = 1.0,
138
+ quality: int = 90,
139
+ ) -> bytes:
140
+ png_bytes = svg_to_png(svg, width=width, height=height, scale=scale)
141
+ img = Image.open(io.BytesIO(png_bytes))
142
+ if img.mode in ('RGBA', 'LA', 'P'):
143
+ bg = Image.new('RGB', img.size, (255, 255, 255))
144
+ bg.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
145
+ img = bg
146
+ buf = io.BytesIO()
147
+ img.save(buf, format='JPEG', quality=quality)
148
+ return buf.getvalue()
149
+
150
+
151
+ def svg_to_webp(
152
+ svg: str,
153
+ width: int | None = None,
154
+ height: int | None = None,
155
+ scale: float = 1.0,
156
+ quality: int = 90,
157
+ ) -> bytes:
158
+ png_bytes = svg_to_png(svg, width=width, height=height, scale=scale)
159
+ img = Image.open(io.BytesIO(png_bytes))
160
+ buf = io.BytesIO()
161
+ img.save(buf, format='WEBP', quality=quality)
162
+ return buf.getvalue()
163
+
164
+
165
+ def write_raster(
166
+ svg: str,
167
+ target: str | BinaryIO,
168
+ format: str = 'png',
169
+ width: int | None = None,
170
+ height: int | None = None,
171
+ scale: float = 1.0,
172
+ ) -> None:
173
+ fmt = format.lower().strip()
174
+ if fmt == 'png':
175
+ data = svg_to_png(svg, width=width, height=height, scale=scale)
176
+ elif fmt in ('jpg', 'jpeg'):
177
+ data = svg_to_jpeg(svg, width=width, height=height, scale=scale)
178
+ elif fmt == 'webp':
179
+ data = svg_to_webp(svg, width=width, height=height, scale=scale)
180
+ else:
181
+ raise ValueError(f"Unsupported raster format: {format}. Supported: 'png', 'jpeg', 'webp'")
182
+
183
+ if isinstance(target, str):
184
+ Path(target).write_bytes(data)
185
+ else:
186
+ target.write(data)
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: plotly-print
3
+ Version: 0.1.0
4
+ Summary: Lightweight, browser-less static image exporter for Plotly
5
+ Project-URL: Homepage, https://github.com/roman-kaczanowski/plotly-print
6
+ Project-URL: Repository, https://github.com/roman-kaczanowski/plotly-print
7
+ Project-URL: Issues, https://github.com/roman-kaczanowski/plotly-print/issues
8
+ Author-email: Roman Kaczanowski <roman.kaczanowski.pl@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: THIRD_PARTY_NOTICES.md
12
+ Keywords: export,headless,kaleido,plotly,png,svg,visualization
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: pillow>=9.0.0
22
+ Requires-Dist: plotly>=5.0.0
23
+ Requires-Dist: quickjs>=1.19.0
24
+ Requires-Dist: resvg-py>=0.4.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # plotly-print
28
+
29
+ [![CI](https://img.shields.io/github/actions/workflow/status/roman-kaczanowski/plotly-print/quality-checks.yml?branch=main&style=flat-square&label=CI)](https://github.com/roman-kaczanowski/plotly-print/actions/workflows/quality-checks.yml) [![PyPI](https://img.shields.io/pypi/v/plotly-print?style=flat-square)](https://pypi.org/project/plotly-print/) [![Python](https://img.shields.io/badge/python-3.12%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://docs.python.org/3/) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
30
+
31
+ Lightweight, browser-less static image exporter for Plotly. No Chrome, Chromium, or Playwright. Figures are rendered with an embedded QuickJS runtime, Pillow font metrics, and `resvg-py` for raster output.
32
+
33
+ Works offline: an official plotly.js cartesian partial is bundled (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)).
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install plotly-print
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ ```python
44
+ import plotly.express as px
45
+ import plotly_print
46
+
47
+ fig = px.scatter(x=[1, 2, 3, 4], y=[10, 11, 12, 13], title='Sales Growth')
48
+
49
+ svg = plotly_print.to_svg(fig)
50
+ png = plotly_print.to_image(fig, format='png', scale=2)
51
+ plotly_print.write_image(fig, 'chart.png')
52
+ ```
53
+
54
+ Patch Plotly's `to_image` / `write_image` to use plotly-print:
55
+
56
+ ```python
57
+ import plotly_print
58
+
59
+ plotly_print.register()
60
+ ```
61
+
62
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and releases.
63
+
64
+ ## Attribution
65
+
66
+ plotly-print is an independent project derived from [Mirage](https://github.com/ProfLear/mirage) by Benjamin Lear (MIT). Bundled plotly.js is copyright Plotly, Inc. (MIT); see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,13 @@
1
+ plotly_print/__init__.py,sha256=I1cd1rMBO4T-iXNZHSvXk56feaRVkQZmGD-revYJNX0,472
2
+ plotly_print/core.py,sha256=o7_nxKGJA6pyfst1hnNrcP9OMcpa9oDA0AdEA-LGN3I,2289
3
+ plotly_print/engine.py,sha256=sJuvT9liDvSUWYdyscABr_YqZnJiJh23QpQj3nJ_iC0,6355
4
+ plotly_print/fonts.py,sha256=2EoV2tPeHNxwTtJk4oQsbESwTtnRCmqbfB2qnB5jNsE,5355
5
+ plotly_print/plotly_hook.py,sha256=RGwoGJLH0QOmTEQUtmuE46B1rlDueKCG-vuqwbf-0iI,2121
6
+ plotly_print/raster.py,sha256=K2MfOzjUJrSXwEqAdbZ2pt9jSb_6oSJB74ku3v0GG4c,5642
7
+ plotly_print/js/micro_dom.js,sha256=rcN8N0BQIYukGhTPMsdIzzmXOothJiZQHx5sqTqrhcQ,45471
8
+ plotly_print/js/plotly-cartesian.min.js,sha256=eVQhUIVaUsVnJg4xNwldvit0Tm1wKlyr5n2iRpQvuno,1500041
9
+ plotly_print-0.1.0.dist-info/METADATA,sha256=Yvrn5sAt0Hnhok5z9qcwz2GYkKBLRGYDAZ3Nbz3XFx0,2813
10
+ plotly_print-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ plotly_print-0.1.0.dist-info/licenses/LICENSE,sha256=kKBHCvoTsEA_8Q6ya-9Dh6KkkvdPVcECv16n2V1PtmY,1107
12
+ plotly_print-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md,sha256=XvdnfmHBOHRcuape6KTVJ45osBjtg4tp5oblhNHst1I,1598
13
+ plotly_print-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Lear
4
+ Copyright (c) 2026 Roman Kaczanowski
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,40 @@
1
+ # Third-party notices
2
+
3
+ ## plotly.js
4
+
5
+ File: `src/plotly_print/js/plotly-cartesian.min.js`
6
+
7
+ Official plotly.js **cartesian** partial (minified) v4.1.1, redistributed for offline /
8
+ isolated environments. The full `plotly.min.js` shipped with the `plotly` Python package
9
+ does not load in the embedded QuickJS runtime, so this smaller official partial is bundled
10
+ instead.
11
+
12
+ Copyright 2012-2026, Plotly, Inc.
13
+ All rights reserved.
14
+ Licensed under the MIT license.
15
+
16
+ ```text
17
+ The MIT License (MIT)
18
+
19
+ Copyright (c) 2014-2026 Plotly, Inc
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in
29
+ all copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
37
+ THE SOFTWARE.
38
+ ```
39
+
40
+ Upstream: https://github.com/plotly/plotly.js