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.
Files changed (74) hide show
  1. batoolset/GUI/__init__.py +0 -0
  2. batoolset/GUI/customdialog.py +136 -0
  3. batoolset/GUI/exception_handler.py +74 -0
  4. batoolset/GUI/list_gui.py +514 -0
  5. batoolset/GUI/multi_image_display.py +1038 -0
  6. batoolset/GUI/open.py +325 -0
  7. batoolset/GUI/overlay_hints.py +45 -0
  8. batoolset/GUI/paint2.py +1369 -0
  9. batoolset/GUI/scrollablepaint.py +1165 -0
  10. batoolset/dialogs/__init__.py +0 -0
  11. batoolset/dialogs/opensave.py +228 -0
  12. batoolset/drawings/__init__.py +0 -0
  13. batoolset/drawings/math_text.py +294 -0
  14. batoolset/drawings/pdf_svg_renderer.py +376 -0
  15. batoolset/drawings/shapes/Position.py +183 -0
  16. batoolset/drawings/shapes/__init__.py +0 -0
  17. batoolset/drawings/shapes/automated_alignment_drawing_refactored.py +346 -0
  18. batoolset/drawings/shapes/circle2d.py +99 -0
  19. batoolset/drawings/shapes/compute_optimal_size_all_merged.py +328 -0
  20. batoolset/drawings/shapes/ellipse2d.py +336 -0
  21. batoolset/drawings/shapes/freehand2d.py +235 -0
  22. batoolset/drawings/shapes/group.py +1985 -0
  23. batoolset/drawings/shapes/image2d.py +3157 -0
  24. batoolset/drawings/shapes/line2d.py +579 -0
  25. batoolset/drawings/shapes/point2d.py +257 -0
  26. batoolset/drawings/shapes/polygon2d.py +357 -0
  27. batoolset/drawings/shapes/polyline2d.py +235 -0
  28. batoolset/drawings/shapes/rect2d.py +388 -0
  29. batoolset/drawings/shapes/rectangle2d.py +601 -0
  30. batoolset/drawings/shapes/scalebar.py +310 -0
  31. batoolset/drawings/shapes/serializablefont.py +133 -0
  32. batoolset/drawings/shapes/serializableguiparams.py +14 -0
  33. batoolset/drawings/shapes/square2d.py +102 -0
  34. batoolset/drawings/shapes/test_publication_style.py +220 -0
  35. batoolset/drawings/shapes/textorientation.py +45 -0
  36. batoolset/drawings/shapes/tools.py +467 -0
  37. batoolset/drawings/shapes/transform2D.py +350 -0
  38. batoolset/drawings/shapes/txt2d.py +615 -0
  39. batoolset/drawings/svg_clip_renderer.py +247 -0
  40. batoolset/drawings/svg_embed.py +165 -0
  41. batoolset/drawings/svg_text_renderer.py +183 -0
  42. batoolset/drawings/widgets/__init__.py +0 -0
  43. batoolset/drawings/widgets/col.py +343 -0
  44. batoolset/drawings/widgets/image_group.py +248 -0
  45. batoolset/drawings/widgets/image_organizer.py +96 -0
  46. batoolset/drawings/widgets/paint.py +184 -0
  47. batoolset/drawings/widgets/vectorial.py +256 -0
  48. batoolset/figure/__init__.py +0 -0
  49. batoolset/figure/alignment.py +822 -0
  50. batoolset/figure/column.py +1117 -0
  51. batoolset/figure/fig_tools.py +285 -0
  52. batoolset/figure/minimal_debugger_scale_etc.py +139 -0
  53. batoolset/figure/panel.py +697 -0
  54. batoolset/figure/row.py +852 -0
  55. batoolset/figure/user_selection.py +287 -0
  56. batoolset/icons/__init__.py +0 -0
  57. batoolset/icons/tools.py +248 -0
  58. batoolset/luts/__init__.py +0 -0
  59. batoolset/luts/lut_minimal_test.py +1308 -0
  60. batoolset/luts/lutcreatorfromformula.py +209 -0
  61. batoolset/luts/luteditor.py +243 -0
  62. batoolset/pyqts/__init__.py +0 -0
  63. batoolset/pyqts/pleasewaitdialog.py +14 -0
  64. batoolset/pyqts/tools.py +760 -0
  65. batoolset/serializations/__init__.py +0 -0
  66. batoolset/serializations/tools.py +1338 -0
  67. batoolset/theme.py +647 -0
  68. batoolset/uis/__init__.py +0 -0
  69. batoolset/uis/blinker.py +56 -0
  70. batoolset_gui-0.2.0.dist-info/METADATA +54 -0
  71. batoolset_gui-0.2.0.dist-info/RECORD +74 -0
  72. batoolset_gui-0.2.0.dist-info/WHEEL +5 -0
  73. batoolset_gui-0.2.0.dist-info/licenses/LICENSE +24 -0
  74. 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("&", "&amp;").replace('"', "&quot;")
57
+ .replace("<", "&lt;").replace(">", "&gt;"))
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