batoolset-gui 0.2.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.
- batoolset/GUI/__init__.py +0 -0
- batoolset/GUI/customdialog.py +136 -0
- batoolset/GUI/exception_handler.py +74 -0
- batoolset/GUI/list_gui.py +514 -0
- batoolset/GUI/multi_image_display.py +1038 -0
- batoolset/GUI/open.py +325 -0
- batoolset/GUI/overlay_hints.py +45 -0
- batoolset/GUI/paint2.py +1369 -0
- batoolset/GUI/scrollablepaint.py +1165 -0
- batoolset/dialogs/__init__.py +0 -0
- batoolset/dialogs/opensave.py +228 -0
- batoolset/drawings/__init__.py +0 -0
- batoolset/drawings/math_text.py +294 -0
- batoolset/drawings/pdf_svg_renderer.py +376 -0
- batoolset/drawings/shapes/Position.py +183 -0
- batoolset/drawings/shapes/__init__.py +0 -0
- batoolset/drawings/shapes/automated_alignment_drawing_refactored.py +346 -0
- batoolset/drawings/shapes/circle2d.py +99 -0
- batoolset/drawings/shapes/compute_optimal_size_all_merged.py +328 -0
- batoolset/drawings/shapes/ellipse2d.py +336 -0
- batoolset/drawings/shapes/freehand2d.py +235 -0
- batoolset/drawings/shapes/group.py +1985 -0
- batoolset/drawings/shapes/image2d.py +3157 -0
- batoolset/drawings/shapes/line2d.py +579 -0
- batoolset/drawings/shapes/point2d.py +257 -0
- batoolset/drawings/shapes/polygon2d.py +357 -0
- batoolset/drawings/shapes/polyline2d.py +235 -0
- batoolset/drawings/shapes/rect2d.py +388 -0
- batoolset/drawings/shapes/rectangle2d.py +601 -0
- batoolset/drawings/shapes/scalebar.py +310 -0
- batoolset/drawings/shapes/serializablefont.py +133 -0
- batoolset/drawings/shapes/serializableguiparams.py +14 -0
- batoolset/drawings/shapes/square2d.py +102 -0
- batoolset/drawings/shapes/test_publication_style.py +220 -0
- batoolset/drawings/shapes/textorientation.py +45 -0
- batoolset/drawings/shapes/tools.py +467 -0
- batoolset/drawings/shapes/transform2D.py +350 -0
- batoolset/drawings/shapes/txt2d.py +615 -0
- batoolset/drawings/svg_clip_renderer.py +247 -0
- batoolset/drawings/svg_embed.py +165 -0
- batoolset/drawings/svg_text_renderer.py +183 -0
- batoolset/drawings/widgets/__init__.py +0 -0
- batoolset/drawings/widgets/col.py +343 -0
- batoolset/drawings/widgets/image_group.py +248 -0
- batoolset/drawings/widgets/image_organizer.py +96 -0
- batoolset/drawings/widgets/paint.py +184 -0
- batoolset/drawings/widgets/vectorial.py +256 -0
- batoolset/figure/__init__.py +0 -0
- batoolset/figure/alignment.py +822 -0
- batoolset/figure/column.py +1117 -0
- batoolset/figure/fig_tools.py +285 -0
- batoolset/figure/minimal_debugger_scale_etc.py +139 -0
- batoolset/figure/panel.py +697 -0
- batoolset/figure/row.py +852 -0
- batoolset/figure/user_selection.py +287 -0
- batoolset/icons/__init__.py +0 -0
- batoolset/icons/tools.py +248 -0
- batoolset/luts/__init__.py +0 -0
- batoolset/luts/lut_minimal_test.py +1308 -0
- batoolset/luts/lutcreatorfromformula.py +209 -0
- batoolset/luts/luteditor.py +243 -0
- batoolset/pyqts/__init__.py +0 -0
- batoolset/pyqts/pleasewaitdialog.py +14 -0
- batoolset/pyqts/tools.py +760 -0
- batoolset/serializations/__init__.py +0 -0
- batoolset/serializations/tools.py +1338 -0
- batoolset/theme.py +647 -0
- batoolset/uis/__init__.py +0 -0
- batoolset/uis/blinker.py +56 -0
- batoolset_gui-0.2.0.dist-info/METADATA +54 -0
- batoolset_gui-0.2.0.dist-info/RECORD +74 -0
- batoolset_gui-0.2.0.dist-info/WHEEL +5 -0
- batoolset_gui-0.2.0.dist-info/licenses/LICENSE +24 -0
- batoolset_gui-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Clip paths for ordinary SVG files drawn with Qt (Qt 6.4-6.11 ignore them).
|
|
2
|
+
|
|
3
|
+
``ClippingSvgRenderer`` is a QSvgRenderer for SVG files that use
|
|
4
|
+
``clip-path`` (Inkscape, Illustrator and plotting libraries write them often):
|
|
5
|
+
it draws the document element by element in document order, with Qt's own
|
|
6
|
+
renderer for each element (``render(painter, id, bounds)`` keeps the parents'
|
|
7
|
+
styles and transforms), and sets the clip itself around every element that
|
|
8
|
+
references a ``<clipPath>``. Elements without clipping inside are drawn whole.
|
|
9
|
+
|
|
10
|
+
Output: PDF / raster painters clip natively, so everything stays vector.
|
|
11
|
+
Figure SVG exports do not draw panels through this renderer: they embed the
|
|
12
|
+
panel's SVG source as is (batoolset.drawings.svg_embed). Only when some other
|
|
13
|
+
code draws it to an SVG painter (Qt's SVG writer drops clipping) is each
|
|
14
|
+
clipped element drawn as an image (600 dpi at the drawn size), clipped;
|
|
15
|
+
everything else stays vector.
|
|
16
|
+
|
|
17
|
+
``make_svg_renderer(path_or_bytes)`` returns a plain QSvgRenderer for SVGs
|
|
18
|
+
without clip paths.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import xml.etree.ElementTree as ET
|
|
24
|
+
|
|
25
|
+
from qtpy.QtCore import QByteArray, QPointF, QRectF, Qt
|
|
26
|
+
from qtpy.QtGui import QColor, QImage, QPaintEngine, QPainter, QPainterPath, QPolygonF, QTransform
|
|
27
|
+
from qtpy.QtSvg import QSvgRenderer
|
|
28
|
+
|
|
29
|
+
from batoolset.drawings.pdf_svg_renderer import parse_path, parse_transform
|
|
30
|
+
|
|
31
|
+
_SVG_NS = "http://www.w3.org/2000/svg"
|
|
32
|
+
_NOT_DRAWN = {"defs", "clipPath", "mask", "title", "desc", "metadata", "style", "script",
|
|
33
|
+
"linearGradient", "radialGradient", "pattern", "marker", "symbol", "filter",
|
|
34
|
+
"font", "font-face", "namedview", "sodipodi:namedview"}
|
|
35
|
+
_URL = re.compile(r"url\(\s*#([^)\s]+)\s*\)")
|
|
36
|
+
_NUM = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
|
|
37
|
+
SVG_OUTPUT_DPI = 600 # more than any journal asks for
|
|
38
|
+
SVG_OUTPUT_MAX_PIXELS = 40_000_000 # per clipped element (an A4 page at 600 dpi is 35 MP)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tag(el):
|
|
42
|
+
return el.tag.split("}", 1)[-1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _clip_ref(el):
|
|
46
|
+
ref = el.get("clip-path")
|
|
47
|
+
if ref is None:
|
|
48
|
+
m = re.search(r"clip-path\s*:\s*([^;]+)", el.get("style") or "")
|
|
49
|
+
ref = m.group(1) if m else None
|
|
50
|
+
m = _URL.search(ref or "")
|
|
51
|
+
return m.group(1) if m else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _f(v, d=0.0):
|
|
55
|
+
try:
|
|
56
|
+
return float(_NUM.match(str(v).strip()).group(0))
|
|
57
|
+
except (AttributeError, TypeError, ValueError):
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _shape_path(el):
|
|
62
|
+
"""QPainterPath of a basic shape / path element, in its own user space."""
|
|
63
|
+
tag = _tag(el)
|
|
64
|
+
p = QPainterPath()
|
|
65
|
+
if tag == "path":
|
|
66
|
+
p = parse_path(el.get("d"))
|
|
67
|
+
elif tag == "rect":
|
|
68
|
+
rx, ry = _f(el.get("rx")), _f(el.get("ry"))
|
|
69
|
+
r = QRectF(_f(el.get("x")), _f(el.get("y")), _f(el.get("width")), _f(el.get("height")))
|
|
70
|
+
if rx or ry:
|
|
71
|
+
p.addRoundedRect(r, rx or ry, ry or rx)
|
|
72
|
+
else:
|
|
73
|
+
p.addRect(r)
|
|
74
|
+
elif tag == "circle":
|
|
75
|
+
r = _f(el.get("r"))
|
|
76
|
+
p.addEllipse(QPointF(_f(el.get("cx")), _f(el.get("cy"))), r, r)
|
|
77
|
+
elif tag == "ellipse":
|
|
78
|
+
p.addEllipse(QPointF(_f(el.get("cx")), _f(el.get("cy"))), _f(el.get("rx")), _f(el.get("ry")))
|
|
79
|
+
elif tag in ("polygon", "polyline"):
|
|
80
|
+
v = [float(x) for x in _NUM.findall(el.get("points") or "")]
|
|
81
|
+
if len(v) >= 4:
|
|
82
|
+
p.addPolygon(QPolygonF([QPointF(v[i], v[i + 1]) for i in range(0, len(v) - 1, 2)]))
|
|
83
|
+
if tag == "polygon":
|
|
84
|
+
p.closeSubpath()
|
|
85
|
+
if (el.get("clip-rule") or el.get("fill-rule")) == "evenodd":
|
|
86
|
+
p.setFillRule(Qt.FillRule.OddEvenFill)
|
|
87
|
+
return p
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ClippingSvgRenderer(QSvgRenderer):
|
|
91
|
+
"""QSvgRenderer that honours clip-path (see the module docstring)."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, svg_bytes):
|
|
94
|
+
root = ET.fromstring(svg_bytes)
|
|
95
|
+
n = 0
|
|
96
|
+
for el in root.iter(): # every element needs an id to be drawn alone
|
|
97
|
+
if el.get("id") is None and isinstance(el.tag, str):
|
|
98
|
+
el.set("id", f"__pyf_el_{n}")
|
|
99
|
+
n += 1
|
|
100
|
+
ET.register_namespace("", _SVG_NS)
|
|
101
|
+
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
|
|
102
|
+
data = ET.tostring(root)
|
|
103
|
+
super().__init__(QByteArray(data))
|
|
104
|
+
self._root = root
|
|
105
|
+
self._ids = {el.get("id"): el for el in root.iter() if el.get("id")}
|
|
106
|
+
self._clipped_below = {}
|
|
107
|
+
self._mark(root)
|
|
108
|
+
|
|
109
|
+
def _mark(self, el):
|
|
110
|
+
"""True if *el* or a descendant references a clip path. Memoised per
|
|
111
|
+
element: whether a *descendant* (not *el* itself) does."""
|
|
112
|
+
below = False
|
|
113
|
+
for child in el:
|
|
114
|
+
below = self._mark(child) or below
|
|
115
|
+
self._clipped_below[el.get("id")] = below
|
|
116
|
+
return below or _clip_ref(el) is not None
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def needed(svg_bytes) -> bool:
|
|
120
|
+
return b"clip-path" in svg_bytes
|
|
121
|
+
|
|
122
|
+
# -- geometry --------------------------------------------------------------
|
|
123
|
+
def _doc_rect(self, el_id):
|
|
124
|
+
"""The element's bounds in document (viewBox) coordinates."""
|
|
125
|
+
return self.transformForElement(el_id).mapRect(self.boundsOnElement(el_id))
|
|
126
|
+
|
|
127
|
+
def _clip_in_doc(self, el):
|
|
128
|
+
"""The clip path *el* references, in document coordinates, or None."""
|
|
129
|
+
cp = self._ids.get(_clip_ref(el))
|
|
130
|
+
if cp is None:
|
|
131
|
+
return None
|
|
132
|
+
# userSpaceOnUse: the referencing element's user space = parents x own transform
|
|
133
|
+
user = parse_transform(el.get("transform")) * self.transformForElement(el.get("id"))
|
|
134
|
+
units_box = cp.get("clipPathUnits") == "objectBoundingBox"
|
|
135
|
+
box = self.boundsOnElement(el.get("id")) if units_box else None
|
|
136
|
+
out = QPainterPath()
|
|
137
|
+
for child in cp:
|
|
138
|
+
p = _shape_path(child)
|
|
139
|
+
if p.isEmpty():
|
|
140
|
+
continue
|
|
141
|
+
t = parse_transform(child.get("transform"))
|
|
142
|
+
if box is not None:
|
|
143
|
+
t = t * QTransform(box.width(), 0, 0, box.height(), box.left(), box.top())
|
|
144
|
+
out = out.united((t * user).map(p)) if not out.isEmpty() else (t * user).map(p)
|
|
145
|
+
nested = self._ids.get(_clip_ref(cp)) if _clip_ref(cp) else None
|
|
146
|
+
if nested is not None and not out.isEmpty():
|
|
147
|
+
inner = self._clip_in_doc(cp)
|
|
148
|
+
if inner is not None:
|
|
149
|
+
out = out.intersected(inner)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
# -- drawing ----------------------------------------------------------------
|
|
153
|
+
def render(self, painter, *args):
|
|
154
|
+
if len(args) == 1 and isinstance(args[0], str):
|
|
155
|
+
return super().render(painter, *args)
|
|
156
|
+
if len(args) >= 2:
|
|
157
|
+
return super().render(painter, *args)
|
|
158
|
+
vb = self.viewBoxF()
|
|
159
|
+
if vb.isEmpty():
|
|
160
|
+
vb = QRectF(QPointF(0, 0), QPointF(self.defaultSize().width(), self.defaultSize().height()))
|
|
161
|
+
bounds = QRectF(args[0]) if args else QRectF(0, 0, vb.width(), vb.height())
|
|
162
|
+
sx = bounds.width() / vb.width()
|
|
163
|
+
sy = bounds.height() / vb.height()
|
|
164
|
+
dx, dy = bounds.left(), bounds.top()
|
|
165
|
+
if self.aspectRatioMode() == Qt.AspectRatioMode.KeepAspectRatio:
|
|
166
|
+
s = min(sx, sy)
|
|
167
|
+
dx += (bounds.width() - vb.width() * s) / 2.0
|
|
168
|
+
dy += (bounds.height() - vb.height() * s) / 2.0
|
|
169
|
+
sx = sy = s
|
|
170
|
+
self._to_target = QTransform(sx, 0, 0, sy, dx - vb.left() * sx, dy - vb.top() * sy)
|
|
171
|
+
engine = painter.paintEngine()
|
|
172
|
+
self._svg_output = engine is not None and engine.type() == QPaintEngine.Type.SVG
|
|
173
|
+
painter.save()
|
|
174
|
+
try:
|
|
175
|
+
for child in self._root:
|
|
176
|
+
self._draw(painter, child, None)
|
|
177
|
+
finally:
|
|
178
|
+
painter.restore()
|
|
179
|
+
|
|
180
|
+
def _draw(self, painter, el, clip):
|
|
181
|
+
if not isinstance(el.tag, str) or _tag(el) in _NOT_DRAWN:
|
|
182
|
+
return
|
|
183
|
+
el_id = el.get("id")
|
|
184
|
+
if _clip_ref(el) is not None:
|
|
185
|
+
own = self._clip_in_doc(el)
|
|
186
|
+
if own is not None:
|
|
187
|
+
clip = own if clip is None else clip.intersected(own)
|
|
188
|
+
if self._clipped_below.get(el_id) and len(el):
|
|
189
|
+
# a clipped element deeper inside: draw the children one by one,
|
|
190
|
+
# each inside this element's own clip (if any) and theirs
|
|
191
|
+
for child in el:
|
|
192
|
+
self._draw(painter, child, clip)
|
|
193
|
+
return
|
|
194
|
+
if not self.elementExists(el_id):
|
|
195
|
+
return
|
|
196
|
+
rect = self._doc_rect(el_id)
|
|
197
|
+
if rect.isEmpty() and rect.width() == 0 and rect.height() == 0:
|
|
198
|
+
return
|
|
199
|
+
target = self._to_target.mapRect(rect)
|
|
200
|
+
if clip is not None and clip.contains(rect):
|
|
201
|
+
# the clip cuts nothing (Matplotlib clips every artist to its axes):
|
|
202
|
+
# draw it plainly, so an SVG output keeps it as a vector shape
|
|
203
|
+
clip = None
|
|
204
|
+
if clip is None:
|
|
205
|
+
super().render(painter, el_id, target)
|
|
206
|
+
elif not self._svg_output:
|
|
207
|
+
painter.save()
|
|
208
|
+
painter.setClipPath(self._to_target.map(clip), Qt.ClipOperation.IntersectClip)
|
|
209
|
+
super().render(painter, el_id, target)
|
|
210
|
+
painter.restore()
|
|
211
|
+
else:
|
|
212
|
+
self._draw_as_image(painter, el_id, target, self._to_target.map(clip))
|
|
213
|
+
|
|
214
|
+
def _draw_as_image(self, painter, el_id, target, clip):
|
|
215
|
+
"""SVG output: the clipped element as an image of its visible part."""
|
|
216
|
+
visible = target.intersected(clip.boundingRect())
|
|
217
|
+
if visible.isEmpty():
|
|
218
|
+
return
|
|
219
|
+
scale = SVG_OUTPUT_DPI / 72.0
|
|
220
|
+
if visible.width() * visible.height() * scale * scale > SVG_OUTPUT_MAX_PIXELS:
|
|
221
|
+
scale = (SVG_OUTPUT_MAX_PIXELS / (visible.width() * visible.height())) ** 0.5
|
|
222
|
+
img = QImage(max(1, int(round(visible.width() * scale))), max(1, int(round(visible.height() * scale))),
|
|
223
|
+
QImage.Format.Format_ARGB32_Premultiplied)
|
|
224
|
+
img.fill(QColor(0, 0, 0, 0))
|
|
225
|
+
p = QPainter(img)
|
|
226
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
227
|
+
p.scale(img.width() / visible.width(), img.height() / visible.height())
|
|
228
|
+
p.translate(-visible.left(), -visible.top())
|
|
229
|
+
p.setClipPath(clip)
|
|
230
|
+
super().render(p, el_id, target)
|
|
231
|
+
p.end()
|
|
232
|
+
painter.drawImage(visible, img, QRectF(img.rect()))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def make_svg_renderer(source):
|
|
236
|
+
"""ClippingSvgRenderer for an SVG with clip paths, else QSvgRenderer.
|
|
237
|
+
*source*: a file path or the SVG bytes."""
|
|
238
|
+
data = source
|
|
239
|
+
if isinstance(source, str):
|
|
240
|
+
with open(source, "rb") as f:
|
|
241
|
+
data = f.read()
|
|
242
|
+
if isinstance(data, (bytes, bytearray)) and ClippingSvgRenderer.needed(bytes(data)):
|
|
243
|
+
try:
|
|
244
|
+
return ClippingSvgRenderer(bytes(data))
|
|
245
|
+
except ET.ParseError:
|
|
246
|
+
pass
|
|
247
|
+
return QSvgRenderer(QByteArray(bytes(data)) if isinstance(data, (bytes, bytearray)) else source)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""SVG export of vector panels without Qt's SVG writer losing anything.
|
|
2
|
+
|
|
3
|
+
Qt draws an SVG panel (an SVG file, a PDF page, a Matplotlib plot) into an SVG
|
|
4
|
+
export through QSvgRenderer -> QSvgGenerator: text becomes outlines and clip
|
|
5
|
+
paths are lost (clipped elements had to be rasterised). Instead, while a
|
|
6
|
+
figure is exported to SVG inside ``collecting()``, each vector panel draws a
|
|
7
|
+
placeholder rectangle in a unique colour (``place``); once Qt has written the
|
|
8
|
+
file, ``embed(svg_text)`` replaces every placeholder by the panel's own SVG
|
|
9
|
+
source: its elements, clip paths and <text> unchanged, under the placeholder's
|
|
10
|
+
transform, fitted like QSvgRenderer fits it, clipped to the panel by an SVG
|
|
11
|
+
clipPath, with ids prefixed so nothing clashes.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import re
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
|
|
19
|
+
from qtpy.QtCore import QRectF
|
|
20
|
+
from qtpy.QtGui import QColor, QPaintEngine, QPolygonF
|
|
21
|
+
|
|
22
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
23
|
+
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
24
|
+
_ACTIVE = []
|
|
25
|
+
_SKIP = {"title", "desc", "metadata", "script"}
|
|
26
|
+
_ROOT_ONLY = {"width", "height", "viewBox", "x", "y", "id", "version", "baseProfile",
|
|
27
|
+
"preserveAspectRatio", "zoomAndPan", "contentScriptType", "contentStyleType"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def active():
|
|
31
|
+
"""The collector of the SVG export in progress, or None."""
|
|
32
|
+
return _ACTIVE[-1] if _ACTIVE else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wants(painter):
|
|
36
|
+
"""True if *painter* writes SVG for an export that embeds vector panels."""
|
|
37
|
+
try:
|
|
38
|
+
return active() is not None and painter.paintEngine().type() == QPaintEngine.Type.SVG
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextlib.contextmanager
|
|
44
|
+
def collecting():
|
|
45
|
+
"""While active, vector panels drawn to an SVG painter become placeholders
|
|
46
|
+
(see module docstring). Yields the collector; call collector.embed(text)."""
|
|
47
|
+
collector = SvgEmbedCollector()
|
|
48
|
+
_ACTIVE.append(collector)
|
|
49
|
+
try:
|
|
50
|
+
yield collector
|
|
51
|
+
finally:
|
|
52
|
+
_ACTIVE.remove(collector)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _xml_attr(value):
|
|
56
|
+
return (str(value).replace("&", "&").replace('"', """)
|
|
57
|
+
.replace("<", "<").replace(">", ">"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tag(el):
|
|
61
|
+
return el.tag.rsplit("}", 1)[-1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _prefixed_svg(svg_bytes, prefix):
|
|
65
|
+
"""(view box (x, y, w, h), serialized children of the root) with every id
|
|
66
|
+
and reference to it prefixed."""
|
|
67
|
+
ET.register_namespace("", SVG_NS)
|
|
68
|
+
ET.register_namespace("xlink", XLINK_NS)
|
|
69
|
+
root = ET.fromstring(svg_bytes)
|
|
70
|
+
vb = root.get("viewBox")
|
|
71
|
+
if vb:
|
|
72
|
+
x, y, w, h = (float(v) for v in vb.replace(",", " ").split())
|
|
73
|
+
else:
|
|
74
|
+
def num(v, default):
|
|
75
|
+
m = re.match(r"\s*([-+]?[\d.]+(?:[eE][-+]?\d+)?)", v or "")
|
|
76
|
+
return float(m.group(1)) if m else default
|
|
77
|
+
x, y, w, h = 0.0, 0.0, num(root.get("width"), 100.0), num(root.get("height"), 100.0)
|
|
78
|
+
ids = {el.get("id") for el in root.iter() if el.get("id")}
|
|
79
|
+
|
|
80
|
+
def fix_refs(value):
|
|
81
|
+
value = re.sub(r"url\(\s*#([^)\s]+)\s*\)",
|
|
82
|
+
lambda m: f"url(#{prefix}{m.group(1)})" if m.group(1) in ids else m.group(0), value)
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
for el in root.iter():
|
|
86
|
+
for k, v in list(el.attrib.items()):
|
|
87
|
+
if k == "id":
|
|
88
|
+
el.set(k, prefix + v)
|
|
89
|
+
elif k in ("href", f"{{{XLINK_NS}}}href") and v.startswith("#") and v[1:] in ids:
|
|
90
|
+
el.set(k, "#" + prefix + v[1:])
|
|
91
|
+
elif "url(" in v:
|
|
92
|
+
el.set(k, fix_refs(v))
|
|
93
|
+
if _tag(el) == "style" and el.text:
|
|
94
|
+
if re.search(r"(^|[}\s])\*\s*\{", el.text):
|
|
95
|
+
el.text = "" # a universal rule would restyle the whole document
|
|
96
|
+
else:
|
|
97
|
+
el.text = fix_refs(el.text)
|
|
98
|
+
parts = [ET.tostring(child, encoding="unicode") for child in root if _tag(child) not in _SKIP]
|
|
99
|
+
# the root's own presentation attributes (fill, font, style...) still apply
|
|
100
|
+
root_attrs = {k: v for k, v in root.attrib.items()
|
|
101
|
+
if "}" not in k and k not in _ROOT_ONLY}
|
|
102
|
+
return (x, y, w, h), "".join(parts), root_attrs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SvgEmbedCollector:
|
|
106
|
+
def __init__(self):
|
|
107
|
+
self.items = [] # (marker, svg bytes, placements)
|
|
108
|
+
self._by_source = {}
|
|
109
|
+
|
|
110
|
+
def place(self, painter, target, svg_bytes, clip=None, keep_aspect=True):
|
|
111
|
+
"""Draw a placeholder for *svg_bytes* fitted into *target* (current
|
|
112
|
+
painter coordinates), visible inside *clip* (a QRectF or QPolygonF,
|
|
113
|
+
same coordinates; default *target*)."""
|
|
114
|
+
key = id(svg_bytes), len(svg_bytes)
|
|
115
|
+
if key not in self._by_source:
|
|
116
|
+
index = len(self.items)
|
|
117
|
+
marker = "#fd%02x%02x" % (1 + index // 250, 1 + index % 250)
|
|
118
|
+
self._by_source[key] = index
|
|
119
|
+
self.items.append((marker, bytes(svg_bytes), []))
|
|
120
|
+
marker, _, placements = self.items[self._by_source[key]]
|
|
121
|
+
poly = QPolygonF(QRectF(clip if isinstance(clip, QRectF) else target)) \
|
|
122
|
+
if not isinstance(clip, QPolygonF) else QPolygonF(clip)
|
|
123
|
+
placements.append((QRectF(target), poly, keep_aspect, painter.opacity()))
|
|
124
|
+
painter.save()
|
|
125
|
+
painter.setOpacity(1.0)
|
|
126
|
+
painter.fillRect(QRectF(target), QColor(marker))
|
|
127
|
+
painter.restore()
|
|
128
|
+
|
|
129
|
+
def embed(self, svg_text):
|
|
130
|
+
"""Replace the placeholders in the written SVG *svg_text*."""
|
|
131
|
+
n = 0
|
|
132
|
+
for k, (marker, source, placements) in enumerate(self.items):
|
|
133
|
+
if not placements:
|
|
134
|
+
continue
|
|
135
|
+
(vx, vy, vw, vh), inner, root_attrs = _prefixed_svg(source, f"pyfp{k}_")
|
|
136
|
+
attrs = {"fill": "#000000", "stroke": "none", **root_attrs}
|
|
137
|
+
attr_text = "".join(f' {a}="{_xml_attr(v)}"' for a, v in attrs.items())
|
|
138
|
+
pattern = re.compile(r'(<g fill="' + re.escape(marker) + r'"[^>]*>\s*)<rect [^>]*/>', re.I)
|
|
139
|
+
|
|
140
|
+
def swap(m):
|
|
141
|
+
nonlocal n
|
|
142
|
+
if not placements:
|
|
143
|
+
return m.group(0)
|
|
144
|
+
target, poly, keep_aspect, opacity = placements.pop(0)
|
|
145
|
+
n += 1
|
|
146
|
+
sx = target.width() / vw
|
|
147
|
+
sy = target.height() / vh
|
|
148
|
+
dx, dy = target.x(), target.y()
|
|
149
|
+
if keep_aspect:
|
|
150
|
+
s = min(sx, sy)
|
|
151
|
+
dx += (target.width() - vw * s) / 2.0
|
|
152
|
+
dy += (target.height() - vh * s) / 2.0
|
|
153
|
+
sx = sy = s
|
|
154
|
+
pts = " ".join(f"{p.x():g},{p.y():g}" for p in poly)
|
|
155
|
+
cid = f"pyfpclip{n}"
|
|
156
|
+
group = re.sub(r'fill="' + re.escape(marker) + '"', 'fill="none"', m.group(1), flags=re.I)
|
|
157
|
+
op = f' opacity="{opacity:g}"' if opacity < 1 else ""
|
|
158
|
+
return (group
|
|
159
|
+
+ f'<defs><clipPath id="{cid}"><polygon points="{pts}"/></clipPath></defs>'
|
|
160
|
+
+ f'<g clip-path="url(#{cid})"{op}><g{attr_text} '
|
|
161
|
+
f'transform="matrix({sx:g},0,0,{sy:g},{dx - vx * sx:g},{dy - vy * sy:g})">'
|
|
162
|
+
+ inner + "</g></g>")
|
|
163
|
+
|
|
164
|
+
svg_text = pattern.sub(swap, svg_text)
|
|
165
|
+
return svg_text
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Draw an SVG's ``<text>`` elements as real text, the rest with Qt's renderer.
|
|
2
|
+
|
|
3
|
+
Qt's SVG renderer draws SVG text as glyph outlines when the target is another
|
|
4
|
+
SVG (QSvgGenerator), so the text of a Matplotlib plot exported with
|
|
5
|
+
``svg.fonttype = 'none'`` still arrived as shapes in the editable PowerPoint /
|
|
6
|
+
Illustrator exports: tick labels and axis titles could not be edited.
|
|
7
|
+
``TextAsTextRenderer`` renders the SVG without its text, then draws each text
|
|
8
|
+
run with ``QPainter.drawText`` at the same place, size, colour and rotation,
|
|
9
|
+
which an SVG painter records as a ``<text>`` element.
|
|
10
|
+
|
|
11
|
+
Single-glyph runs on one baseline (Matplotlib writes mathtext one glyph per
|
|
12
|
+
``<tspan>``) are joined into one run, so a label stays one editable string.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
|
|
19
|
+
from qtpy.QtCore import QByteArray, QPointF, QRectF, Qt
|
|
20
|
+
from qtpy.QtGui import QColor, QFont, QFontMetricsF, QTransform
|
|
21
|
+
from qtpy.QtSvg import QSvgRenderer
|
|
22
|
+
|
|
23
|
+
from batoolset.drawings.pdf_svg_renderer import parse_transform
|
|
24
|
+
|
|
25
|
+
_SVG_NS = "http://www.w3.org/2000/svg"
|
|
26
|
+
_NUM = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tag(el):
|
|
30
|
+
return el.tag.split("}", 1)[-1] if isinstance(el.tag, str) else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _f(v, d=0.0):
|
|
34
|
+
try:
|
|
35
|
+
return float(_NUM.match(str(v).strip()).group(0))
|
|
36
|
+
except (AttributeError, TypeError, ValueError):
|
|
37
|
+
return d
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _style(el, inherited):
|
|
41
|
+
st = dict(inherited)
|
|
42
|
+
for part in (el.get("style") or "").split(";"):
|
|
43
|
+
if ":" in part:
|
|
44
|
+
k, v = part.split(":", 1)
|
|
45
|
+
st[k.strip()] = v.strip()
|
|
46
|
+
for k in ("font-size", "font-family", "font-weight", "font-style", "fill", "text-anchor", "opacity",
|
|
47
|
+
"fill-opacity"):
|
|
48
|
+
if el.get(k) is not None:
|
|
49
|
+
st[k] = el.get(k)
|
|
50
|
+
return st
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _family(value):
|
|
54
|
+
"""First family of a CSS font-family list, without quotes."""
|
|
55
|
+
first = (value or "").split(",")[0].strip().strip("'\"")
|
|
56
|
+
return first or "Arial"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _color(value, opacity):
|
|
60
|
+
c = QColor(0, 0, 0)
|
|
61
|
+
if value and value != "none":
|
|
62
|
+
m = re.match(r"rgb\(\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*\)", value)
|
|
63
|
+
if m:
|
|
64
|
+
c = QColor(int(float(m.group(1))), int(float(m.group(2))), int(float(m.group(3))))
|
|
65
|
+
else:
|
|
66
|
+
c = QColor(value)
|
|
67
|
+
if not c.isValid():
|
|
68
|
+
c = QColor(0, 0, 0)
|
|
69
|
+
c.setAlphaF(max(0.0, min(1.0, opacity)))
|
|
70
|
+
return c
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _runs_of(text_el, style, transform):
|
|
74
|
+
"""[(text, x, y, style, transform)] for a <text> element and its tspans."""
|
|
75
|
+
runs = []
|
|
76
|
+
own_x = _f(text_el.get("x"))
|
|
77
|
+
own_y = _f(text_el.get("y"))
|
|
78
|
+
if text_el.text and text_el.text.strip():
|
|
79
|
+
runs.append([text_el.text, own_x, own_y, style, transform])
|
|
80
|
+
for span in text_el:
|
|
81
|
+
if _tag(span) != "tspan":
|
|
82
|
+
continue
|
|
83
|
+
st = _style(span, style)
|
|
84
|
+
x = _f(span.get("x"), own_x)
|
|
85
|
+
y = _f(span.get("y"), own_y)
|
|
86
|
+
if span.text:
|
|
87
|
+
runs.append([span.text.replace("\xa0", " "), x, y, st, transform])
|
|
88
|
+
# mathtext writes its glyphs in drawing order, not reading order
|
|
89
|
+
runs.sort(key=lambda r: (round(r[2], 3), r[1]))
|
|
90
|
+
merged = []
|
|
91
|
+
for run in runs:
|
|
92
|
+
prev = merged[-1] if merged else None
|
|
93
|
+
if (prev is not None and abs(prev[2] - run[2]) < 1e-6 and prev[4] == run[4]
|
|
94
|
+
and prev[3].get("font-size") == run[3].get("font-size")
|
|
95
|
+
and prev[3].get("text-anchor", "start") == "start"):
|
|
96
|
+
prev[0] += run[0]
|
|
97
|
+
else:
|
|
98
|
+
merged.append(run)
|
|
99
|
+
return merged
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TextAsTextRenderer(QSvgRenderer):
|
|
103
|
+
"""QSvgRenderer for an SVG whose text should stay text (see module doc)."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, svg_bytes, make_renderer=None):
|
|
106
|
+
root = ET.fromstring(svg_bytes)
|
|
107
|
+
self._texts = []
|
|
108
|
+
self._collect(root, QTransform(), {}, None)
|
|
109
|
+
ET.register_namespace("", _SVG_NS)
|
|
110
|
+
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
|
|
111
|
+
stripped = ET.tostring(root)
|
|
112
|
+
super().__init__(QByteArray(stripped))
|
|
113
|
+
# the drawing without its text, by the usual renderer (clip paths...)
|
|
114
|
+
self._inner = make_renderer(stripped) if make_renderer is not None else QSvgRenderer(QByteArray(stripped))
|
|
115
|
+
self._pyf_svg_source = bytes(svg_bytes)
|
|
116
|
+
|
|
117
|
+
def _collect(self, el, parent_t, parent_style, parent):
|
|
118
|
+
t = parse_transform(el.get("transform")) * parent_t
|
|
119
|
+
style = _style(el, parent_style)
|
|
120
|
+
for child in list(el):
|
|
121
|
+
if _tag(child) == "text":
|
|
122
|
+
child_t = parse_transform(child.get("transform")) * t
|
|
123
|
+
self._texts.extend(_runs_of(child, _style(child, style), child_t))
|
|
124
|
+
el.remove(child)
|
|
125
|
+
elif _tag(child) not in ("defs", "clipPath", "style", "metadata"):
|
|
126
|
+
self._collect(child, t, style, el)
|
|
127
|
+
|
|
128
|
+
def text_runs(self):
|
|
129
|
+
return [(r[0], r[1], r[2]) for r in self._texts]
|
|
130
|
+
|
|
131
|
+
def setAspectRatioMode(self, mode):
|
|
132
|
+
super().setAspectRatioMode(mode)
|
|
133
|
+
self._inner.setAspectRatioMode(mode)
|
|
134
|
+
|
|
135
|
+
def render(self, painter, *args):
|
|
136
|
+
if len(args) == 1 and isinstance(args[0], str) or len(args) >= 2:
|
|
137
|
+
return self._inner.render(painter, *args)
|
|
138
|
+
self._inner.render(painter, *args)
|
|
139
|
+
vb = self.viewBoxF()
|
|
140
|
+
if vb.isEmpty():
|
|
141
|
+
vb = QRectF(QPointF(0, 0), QPointF(self.defaultSize().width(), self.defaultSize().height()))
|
|
142
|
+
bounds = QRectF(args[0]) if args else QRectF(0, 0, vb.width(), vb.height())
|
|
143
|
+
sx = bounds.width() / vb.width()
|
|
144
|
+
sy = bounds.height() / vb.height()
|
|
145
|
+
dx, dy = bounds.left(), bounds.top()
|
|
146
|
+
if self.aspectRatioMode() == Qt.AspectRatioMode.KeepAspectRatio:
|
|
147
|
+
s = min(sx, sy)
|
|
148
|
+
dx += (bounds.width() - vb.width() * s) / 2.0
|
|
149
|
+
dy += (bounds.height() - vb.height() * s) / 2.0
|
|
150
|
+
sx = sy = s
|
|
151
|
+
to_target = QTransform(sx, 0, 0, sy, dx - vb.left() * sx, dy - vb.top() * sy)
|
|
152
|
+
device = painter.device()
|
|
153
|
+
dpi = device.logicalDpiY() if device is not None and device.logicalDpiY() > 0 else 72
|
|
154
|
+
world = painter.worldTransform()
|
|
155
|
+
for text, x, y, st, t in self._texts:
|
|
156
|
+
size = _f(st.get("font-size"), 10.0)
|
|
157
|
+
if size <= 0:
|
|
158
|
+
continue
|
|
159
|
+
font = QFont(_family(st.get("font-family")))
|
|
160
|
+
font.setPointSizeF(size * 72.0 / dpi) # 1 SVG unit = 1 device pixel here
|
|
161
|
+
weight = (st.get("font-weight") or "").lower()
|
|
162
|
+
font.setBold(weight in ("bold", "bolder") or _f(weight, 400) >= 600)
|
|
163
|
+
font.setItalic((st.get("font-style") or "").lower() in ("italic", "oblique"))
|
|
164
|
+
opacity = _f(st.get("opacity"), 1.0) * _f(st.get("fill-opacity"), 1.0)
|
|
165
|
+
anchor = st.get("text-anchor", "start")
|
|
166
|
+
width = QFontMetricsF(font, device).horizontalAdvance(text) if anchor != "start" else 0.0
|
|
167
|
+
shift = width / 2.0 if anchor == "middle" else width if anchor == "end" else 0.0
|
|
168
|
+
painter.save()
|
|
169
|
+
painter.setWorldTransform(t * to_target * world)
|
|
170
|
+
painter.setFont(font)
|
|
171
|
+
painter.setPen(_color(st.get("fill"), opacity))
|
|
172
|
+
painter.drawText(QPointF(x - shift, y), text)
|
|
173
|
+
painter.restore()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def make_text_renderer(svg_bytes, make_renderer=None):
|
|
177
|
+
"""A TextAsTextRenderer for *svg_bytes*, or None when it has no text."""
|
|
178
|
+
if b"<text" not in bytes(svg_bytes):
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return TextAsTextRenderer(bytes(svg_bytes), make_renderer=make_renderer)
|
|
182
|
+
except ET.ParseError:
|
|
183
|
+
return None
|
|
File without changes
|