setiastrosuitepro 1.6.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.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
# pro/signature_insert.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
|
7
|
+
from PyQt6.QtGui import (
|
|
8
|
+
QImage, QPixmap, QPainter, QColor, QPen, QTransform, QIcon, QFont, QPainterPath, QFontMetricsF, QFontDatabase, QTextCursor, QTextCharFormat, QBrush
|
|
9
|
+
)
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QLabel, QPushButton,
|
|
12
|
+
QSlider, QCheckBox, QColorDialog, QComboBox, QFileDialog, QInputDialog, QMenu,
|
|
13
|
+
QMessageBox, QWidget, QGraphicsView, QGraphicsScene, QGraphicsItem,QFontComboBox, QGraphicsTextItem,
|
|
14
|
+
QGraphicsPixmapItem, QGraphicsEllipseItem, QGraphicsRectItem, QSpinBox
|
|
15
|
+
)
|
|
16
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _np_to_qimage_rgb(a: np.ndarray) -> QImage:
|
|
20
|
+
a = np.asarray(a, dtype=np.float32)
|
|
21
|
+
a = np.clip(a, 0.0, 1.0)
|
|
22
|
+
if a.ndim == 2:
|
|
23
|
+
a = a[..., None].repeat(3, axis=2)
|
|
24
|
+
if a.shape[2] != 3:
|
|
25
|
+
a = a[:, :, :3]
|
|
26
|
+
u8 = (a * 255.0).astype(np.uint8)
|
|
27
|
+
h, w = u8.shape[:2]
|
|
28
|
+
return QImage(u8.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
|
|
29
|
+
|
|
30
|
+
def _qimage_to_np_rgba(img: QImage) -> np.ndarray:
|
|
31
|
+
q = img.convertToFormat(QImage.Format.Format_RGBA8888)
|
|
32
|
+
w, h = q.width(), q.height()
|
|
33
|
+
ptr = q.bits(); ptr.setsize(h * q.bytesPerLine())
|
|
34
|
+
buf = np.frombuffer(ptr, dtype=np.uint8).reshape((h, q.bytesPerLine()))
|
|
35
|
+
arr = buf[:, :w*4].reshape((h, w, 4)).astype(np.float32) / 255.0
|
|
36
|
+
return arr
|
|
37
|
+
|
|
38
|
+
def _anchor_point(base_w: int, base_h: int, ins_w: int, ins_h: int,
|
|
39
|
+
key: str, mx: int, my: int) -> QPointF:
|
|
40
|
+
# compute top-left anchor by key + margins
|
|
41
|
+
left = 0 + mx
|
|
42
|
+
right = base_w - ins_w - mx
|
|
43
|
+
top = 0 + my
|
|
44
|
+
bottom = base_h - ins_h - my
|
|
45
|
+
center_x = (base_w - ins_w) / 2
|
|
46
|
+
center_y = (base_h - ins_h) / 2
|
|
47
|
+
table = {
|
|
48
|
+
"top_left": QPointF(left, top),
|
|
49
|
+
"top_center": QPointF(center_x, top),
|
|
50
|
+
"top_right": QPointF(right, top),
|
|
51
|
+
"middle_left": QPointF(left, center_y),
|
|
52
|
+
"center": QPointF(center_x, center_y),
|
|
53
|
+
"middle_right": QPointF(right, center_y),
|
|
54
|
+
"bottom_left": QPointF(left, bottom),
|
|
55
|
+
"bottom_center": QPointF(center_x, bottom),
|
|
56
|
+
"bottom_right": QPointF(right, bottom),
|
|
57
|
+
}
|
|
58
|
+
return table.get(key, QPointF(right, bottom)) # default BR
|
|
59
|
+
|
|
60
|
+
def apply_signature_preset_to_doc(doc, preset: dict) -> np.ndarray:
|
|
61
|
+
"""
|
|
62
|
+
Headless apply of signature/insert using a preset.
|
|
63
|
+
Preset fields (all optional except file_path):
|
|
64
|
+
- file_path: str (PNG recommended; alpha preserved)
|
|
65
|
+
- position: str in {"top_left","top_center","top_right",
|
|
66
|
+
"middle_left","center","middle_right",
|
|
67
|
+
"bottom_left","bottom_center","bottom_right"}
|
|
68
|
+
- margin_x: int pixels (default 20)
|
|
69
|
+
- margin_y: int pixels (default 20)
|
|
70
|
+
- scale: percent (default 100)
|
|
71
|
+
- rotation: degrees (default 0)
|
|
72
|
+
- opacity: percent (default 100)
|
|
73
|
+
Returns: RGB float32 image in [0,1]
|
|
74
|
+
"""
|
|
75
|
+
fp = str(preset.get("file_path", "")).strip()
|
|
76
|
+
if not fp:
|
|
77
|
+
raise ValueError("Preset missing 'file_path'.")
|
|
78
|
+
|
|
79
|
+
# base → RGB
|
|
80
|
+
base = np.asarray(getattr(doc, "image", None), dtype=np.float32)
|
|
81
|
+
if base is None:
|
|
82
|
+
raise RuntimeError("Document has no image.")
|
|
83
|
+
if base.ndim == 2:
|
|
84
|
+
base_rgb = np.repeat(base[:, :, None], 3, axis=2)
|
|
85
|
+
elif base.ndim == 3 and base.shape[2] == 1:
|
|
86
|
+
base_rgb = np.repeat(base, 3, axis=2)
|
|
87
|
+
else:
|
|
88
|
+
base_rgb = base[:, :, :3]
|
|
89
|
+
base_rgb = np.clip(base_rgb, 0, 1)
|
|
90
|
+
|
|
91
|
+
# canvas (ARGB32 so we can keep alpha while painting)
|
|
92
|
+
canvas = QImage(base_rgb.shape[1], base_rgb.shape[0], QImage.Format.Format_ARGB32)
|
|
93
|
+
canvas.fill(Qt.GlobalColor.transparent)
|
|
94
|
+
p = QPainter(canvas)
|
|
95
|
+
# draw base first (opaque)
|
|
96
|
+
p.drawImage(QPointF(0, 0), _np_to_qimage_rgb(base_rgb))
|
|
97
|
+
|
|
98
|
+
# load insert (alpha preserved)
|
|
99
|
+
ins_img = QImage(fp)
|
|
100
|
+
if ins_img.isNull():
|
|
101
|
+
p.end()
|
|
102
|
+
raise ValueError(f"Could not load insert image: {fp}")
|
|
103
|
+
|
|
104
|
+
# parameters
|
|
105
|
+
pos_key = str(preset.get("position", "bottom_right"))
|
|
106
|
+
mx = int(preset.get("margin_x", 20))
|
|
107
|
+
my = int(preset.get("margin_y", 20))
|
|
108
|
+
scale = float(preset.get("scale", 100)) / 100.0
|
|
109
|
+
rotation = float(preset.get("rotation", 0.0))
|
|
110
|
+
opacity = max(0.0, min(1.0, float(preset.get("opacity", 100)) / 100.0))
|
|
111
|
+
|
|
112
|
+
# transform: scale + rotate around center, then translate to anchor
|
|
113
|
+
iw, ih = ins_img.width(), ins_img.height()
|
|
114
|
+
aw = max(1, int(round(iw * scale)))
|
|
115
|
+
ah = max(1, int(round(ih * scale)))
|
|
116
|
+
|
|
117
|
+
# NOTE: we can let QPainter scale it by world transform (keeps alpha)
|
|
118
|
+
# Compute anchor for the post-transform bounding rect.
|
|
119
|
+
# For rotation, the item’s visual bbox changes; the usual UX expectation is:
|
|
120
|
+
# "put the visual center on the anchor then offset by half of its size".
|
|
121
|
+
# We do: transform about center, then translate so the *visual* top-left hits the anchor.
|
|
122
|
+
t = QTransform()
|
|
123
|
+
t.translate(aw/2, ah/2)
|
|
124
|
+
t.rotate(rotation)
|
|
125
|
+
t.scale(scale, scale) # scale first or last works since we rotate around center
|
|
126
|
+
t.translate(-iw/2, -ih/2)
|
|
127
|
+
|
|
128
|
+
# Find visual bbox of transformed image to compute margins correctly
|
|
129
|
+
transformed_rect = t.mapRect(QRectF(0, 0, iw, ih))
|
|
130
|
+
vis_w, vis_h = transformed_rect.width(), transformed_rect.height()
|
|
131
|
+
|
|
132
|
+
anchor = _anchor_point(base_rgb.shape[1], base_rgb.shape[0], int(round(vis_w)), int(round(vis_h)), pos_key, mx, my)
|
|
133
|
+
|
|
134
|
+
# Now shift so that the transformed visual top-left lands at anchor
|
|
135
|
+
t2 = QTransform(t)
|
|
136
|
+
t2.translate(anchor.x() - transformed_rect.left(), anchor.y() - transformed_rect.top())
|
|
137
|
+
|
|
138
|
+
p.setOpacity(opacity)
|
|
139
|
+
p.setWorldTransform(t2, combine=False)
|
|
140
|
+
p.drawImage(QPointF(0, 0), ins_img)
|
|
141
|
+
p.end()
|
|
142
|
+
|
|
143
|
+
# back to numpy (drop alpha → RGB)
|
|
144
|
+
out_rgba = _qimage_to_np_rgba(canvas)
|
|
145
|
+
out_rgb = out_rgba[:, :, :3]
|
|
146
|
+
out_rgb = np.clip(out_rgb, 0.0, 1.0).astype(np.float32, copy=False)
|
|
147
|
+
return out_rgb
|
|
148
|
+
|
|
149
|
+
# --------------------------- Graphics helpers ---------------------------
|
|
150
|
+
|
|
151
|
+
class TransformHandle(QGraphicsEllipseItem):
|
|
152
|
+
"""
|
|
153
|
+
A small circular handle that sits on the top-right of the item's local
|
|
154
|
+
bounds and allows scale+rotation by dragging. Works for any QGraphicsItem
|
|
155
|
+
that has boundingRect()/setScale()/setRotation().
|
|
156
|
+
"""
|
|
157
|
+
def __init__(self, parent_item: QGraphicsItem, scene: QGraphicsScene):
|
|
158
|
+
super().__init__(-5, -5, 10, 10)
|
|
159
|
+
self.parent_item = parent_item
|
|
160
|
+
self.scene = scene
|
|
161
|
+
|
|
162
|
+
self.setBrush(QColor("blue"))
|
|
163
|
+
self.setPen(QPen(Qt.PenStyle.SolidLine))
|
|
164
|
+
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
|
165
|
+
self.setZValue(2)
|
|
166
|
+
|
|
167
|
+
self.setFlags(
|
|
168
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
|
|
169
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsFocusable |
|
|
170
|
+
QGraphicsItem.GraphicsItemFlag.ItemIgnoresParentOpacity |
|
|
171
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
172
|
+
)
|
|
173
|
+
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
|
174
|
+
self.setAcceptHoverEvents(True)
|
|
175
|
+
|
|
176
|
+
self.initial_distance = None
|
|
177
|
+
self.initial_angle = None
|
|
178
|
+
self.initial_scale = max(0.05, float(self.parent_item.scale()) if hasattr(self.parent_item, "scale") else 1.0)
|
|
179
|
+
|
|
180
|
+
scene.addItem(self)
|
|
181
|
+
self.update_position()
|
|
182
|
+
|
|
183
|
+
def update_position(self):
|
|
184
|
+
corner = self.parent_item.boundingRect().topRight()
|
|
185
|
+
scene_corner = self.parent_item.mapToScene(corner)
|
|
186
|
+
self.setPos(scene_corner)
|
|
187
|
+
|
|
188
|
+
def mousePressEvent(self, e):
|
|
189
|
+
center = self.parent_item.mapToScene(self.parent_item.boundingRect().center())
|
|
190
|
+
delta = self.scenePos() - center
|
|
191
|
+
self.initial_distance = math.hypot(delta.x(), delta.y())
|
|
192
|
+
self.initial_angle = math.degrees(math.atan2(delta.y(), delta.x()))
|
|
193
|
+
sc = getattr(self.parent_item, "scale", None)
|
|
194
|
+
self.initial_scale = sc() if callable(sc) else 1.0
|
|
195
|
+
e.accept()
|
|
196
|
+
|
|
197
|
+
def mouseMoveEvent(self, e):
|
|
198
|
+
center = self.parent_item.mapToScene(self.parent_item.boundingRect().center())
|
|
199
|
+
new_pos = self.mapToScene(e.pos())
|
|
200
|
+
delta = new_pos - center
|
|
201
|
+
dist = math.hypot(delta.x(), delta.y())
|
|
202
|
+
ang = math.degrees(math.atan2(delta.y(), delta.x()))
|
|
203
|
+
|
|
204
|
+
# scale
|
|
205
|
+
s = (dist / self.initial_distance) if self.initial_distance else 1.0
|
|
206
|
+
new_scale = max(0.05, float(self.initial_scale) * s)
|
|
207
|
+
if hasattr(self.parent_item, "setScale"):
|
|
208
|
+
self.parent_item.setScale(new_scale)
|
|
209
|
+
|
|
210
|
+
# rotate
|
|
211
|
+
if hasattr(self.parent_item, "setRotation"):
|
|
212
|
+
self.parent_item.setRotation(ang - self.initial_angle)
|
|
213
|
+
|
|
214
|
+
self.update_position()
|
|
215
|
+
e.accept()
|
|
216
|
+
|
|
217
|
+
def mouseReleaseEvent(self, e):
|
|
218
|
+
self.initial_distance = None
|
|
219
|
+
self.initial_angle = None
|
|
220
|
+
sc = getattr(self.parent_item, "scale", None)
|
|
221
|
+
self.initial_scale = sc() if callable(sc) else 1.0
|
|
222
|
+
e.accept()
|
|
223
|
+
|
|
224
|
+
class OutlinedTextItem(QGraphicsTextItem):
|
|
225
|
+
"""
|
|
226
|
+
Text item that paints a solid fill with an optional outline.
|
|
227
|
+
It still supports selection/transform/opacity like other items.
|
|
228
|
+
"""
|
|
229
|
+
def __init__(self, text: str, font: QFont, fill: QColor, outline: QColor | None, outline_w: float = 0.0):
|
|
230
|
+
super().__init__(text)
|
|
231
|
+
self._font = font
|
|
232
|
+
self._fill = QColor(fill)
|
|
233
|
+
self._outline = QColor(outline) if outline else None
|
|
234
|
+
self._outline_w = float(max(0.0, outline_w))
|
|
235
|
+
self.setFont(font)
|
|
236
|
+
self.setDefaultTextColor(self._fill)
|
|
237
|
+
|
|
238
|
+
self.setFlags(
|
|
239
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
|
|
240
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
|
241
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsFocusable |
|
|
242
|
+
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
243
|
+
)
|
|
244
|
+
self.setTransformOriginPoint(self.boundingRect().center())
|
|
245
|
+
self.setZValue(1)
|
|
246
|
+
|
|
247
|
+
# simple multi-line path builder
|
|
248
|
+
def _text_path(self) -> QPainterPath:
|
|
249
|
+
path = QPainterPath()
|
|
250
|
+
fm = QFontMetricsF(self._font)
|
|
251
|
+
lh = fm.lineSpacing()
|
|
252
|
+
y = 0.0
|
|
253
|
+
for i, line in enumerate(self.toPlainText().splitlines() or [""]):
|
|
254
|
+
# baseline at +ascent for each line
|
|
255
|
+
path.addText(0, y + fm.ascent(), self._font, line)
|
|
256
|
+
y += lh
|
|
257
|
+
return path
|
|
258
|
+
|
|
259
|
+
def paint(self, painter, option, widget=None):
|
|
260
|
+
# draw fill as normal (fast)
|
|
261
|
+
if not self._outline or self._outline_w <= 0.0:
|
|
262
|
+
return super().paint(painter, option, widget)
|
|
263
|
+
|
|
264
|
+
# with outline: draw a vector path so the stroke is crisp after scaling
|
|
265
|
+
painter.save()
|
|
266
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
267
|
+
|
|
268
|
+
path = self._text_path()
|
|
269
|
+
|
|
270
|
+
# center origin like the base class would—translate so (0,0) is our item’s top-left
|
|
271
|
+
painter.translate(self.boundingRect().topLeft())
|
|
272
|
+
|
|
273
|
+
pen = QPen(self._outline, max(0.0, self._outline_w), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
|
|
274
|
+
painter.setPen(pen)
|
|
275
|
+
painter.setBrush(QBrush(self._fill))
|
|
276
|
+
painter.drawPath(path)
|
|
277
|
+
|
|
278
|
+
painter.restore()
|
|
279
|
+
|
|
280
|
+
# accessors used by the controls
|
|
281
|
+
def set_fill(self, c: QColor):
|
|
282
|
+
self._fill = QColor(c)
|
|
283
|
+
self.setDefaultTextColor(self._fill)
|
|
284
|
+
self.update()
|
|
285
|
+
|
|
286
|
+
def set_outline(self, c: QColor | None, w: float):
|
|
287
|
+
self._outline = QColor(c) if c else None
|
|
288
|
+
self._outline_w = float(max(0.0, w))
|
|
289
|
+
self.update()
|
|
290
|
+
|
|
291
|
+
def set_font(self, f: QFont):
|
|
292
|
+
self._font = f
|
|
293
|
+
self.setFont(f)
|
|
294
|
+
self.update()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class InsertView(QGraphicsView):
|
|
299
|
+
"""Pannable view + Ctrl+wheel zoom, with a right-click menu on inserts."""
|
|
300
|
+
def __init__(self, scene: QGraphicsScene, owner: "SignatureInsertDialogPro"):
|
|
301
|
+
super().__init__(scene)
|
|
302
|
+
self.owner = owner
|
|
303
|
+
self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
|
|
304
|
+
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
|
305
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
306
|
+
self.zoom_factor = 1.0
|
|
307
|
+
self.min_zoom, self.max_zoom = 0.10, 10.0
|
|
308
|
+
|
|
309
|
+
# --- zoom/pan ---
|
|
310
|
+
def wheelEvent(self, e):
|
|
311
|
+
if e.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
312
|
+
step = 1.15 if e.angleDelta().y() > 0 else 1/1.15
|
|
313
|
+
self.set_zoom(self.zoom_factor * step)
|
|
314
|
+
e.accept()
|
|
315
|
+
return
|
|
316
|
+
super().wheelEvent(e)
|
|
317
|
+
|
|
318
|
+
def set_zoom(self, z):
|
|
319
|
+
z = max(self.min_zoom, min(self.max_zoom, z))
|
|
320
|
+
self.zoom_factor = z
|
|
321
|
+
self.setTransform(QTransform().scale(z, z))
|
|
322
|
+
|
|
323
|
+
def zoom_in(self): self.set_zoom(self.zoom_factor * 1.15)
|
|
324
|
+
def zoom_out(self): self.set_zoom(self.zoom_factor / 1.15)
|
|
325
|
+
def fit_to_view(self):
|
|
326
|
+
r = self.scene().itemsBoundingRect()
|
|
327
|
+
if r.isEmpty(): return
|
|
328
|
+
self.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
|
|
329
|
+
self.zoom_factor = 1.0 # logical reset
|
|
330
|
+
|
|
331
|
+
# --- context menu to snap inserts ---
|
|
332
|
+
def contextMenuEvent(self, e):
|
|
333
|
+
scene_pos = self.mapToScene(e.pos())
|
|
334
|
+
item = self.scene().itemAt(scene_pos, self.transform())
|
|
335
|
+
|
|
336
|
+
# If user clicked the child rect, use the parent pixmap
|
|
337
|
+
if isinstance(item, QGraphicsRectItem) and item.parentItem() in self.owner.inserts:
|
|
338
|
+
item = item.parentItem()
|
|
339
|
+
|
|
340
|
+
if ((isinstance(item, QGraphicsPixmapItem) and item in self.owner.inserts) or
|
|
341
|
+
isinstance(item, QGraphicsTextItem)):
|
|
342
|
+
m = QMenu(self)
|
|
343
|
+
pos = {
|
|
344
|
+
"Top-Left":"top_left", "Top-Center":"top_center", "Top-Right":"top_right",
|
|
345
|
+
"Middle-Left":"middle_left","Center":"center","Middle-Right":"middle_right",
|
|
346
|
+
"Bottom-Left":"bottom_left","Bottom-Center":"bottom_center","Bottom-Right":"bottom_right"
|
|
347
|
+
}
|
|
348
|
+
for label, key in pos.items():
|
|
349
|
+
m.addAction(label, lambda k=key, it=item: self.owner.send_insert_to_position(it, k))
|
|
350
|
+
m.exec(e.globalPos())
|
|
351
|
+
return
|
|
352
|
+
else:
|
|
353
|
+
super().contextMenuEvent(e)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --------------------------- Main dialog ---------------------------
|
|
357
|
+
|
|
358
|
+
class SignatureInsertDialogPro(QDialog):
|
|
359
|
+
"""
|
|
360
|
+
Add one or more overlays (“signatures/inserts”) on top of the active doc,
|
|
361
|
+
transform them interactively, then bake into the doc.
|
|
362
|
+
"""
|
|
363
|
+
def __init__(self, parent, doc, icon: QIcon | None = None):
|
|
364
|
+
super().__init__(parent)
|
|
365
|
+
self.setWindowTitle("Signature / Insert")
|
|
366
|
+
if icon:
|
|
367
|
+
try: self.setWindowIcon(icon)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
import logging
|
|
370
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
371
|
+
|
|
372
|
+
self.doc = doc
|
|
373
|
+
self.scene = QGraphicsScene(self)
|
|
374
|
+
self.view = InsertView(self.scene, self)
|
|
375
|
+
|
|
376
|
+
self.inserts: list[QGraphicsPixmapItem] = []
|
|
377
|
+
self.bounding_boxes: list[QGraphicsRectItem] = []
|
|
378
|
+
self.bounding_boxes_enabled = True
|
|
379
|
+
self.bounding_box_pen = QPen(QColor("red"), 2, Qt.PenStyle.DashLine)
|
|
380
|
+
self.text_inserts: list[OutlinedTextItem] = []
|
|
381
|
+
self.scene.selectionChanged.connect(self._on_selection_changed)
|
|
382
|
+
# Handle sync timer (keeps the handle parked on the item corner)
|
|
383
|
+
self._timer = QTimer(self); self._timer.timeout.connect(self._sync_handles); self._timer.start(16)
|
|
384
|
+
|
|
385
|
+
self._build_ui()
|
|
386
|
+
self._update_base_image()
|
|
387
|
+
self.resize(1000, 680)
|
|
388
|
+
|
|
389
|
+
# -------- UI ----------
|
|
390
|
+
def _build_ui(self):
|
|
391
|
+
root = QHBoxLayout(self)
|
|
392
|
+
|
|
393
|
+
# ---- LEFT COLUMN ------------------------------------------------------
|
|
394
|
+
col = QVBoxLayout()
|
|
395
|
+
|
|
396
|
+
# Alpha hint (always visible – simple, clear)
|
|
397
|
+
alpha_hint = QLabel("Tip: Transparent signatures — use “Load from File” to preserve PNG alpha. "
|
|
398
|
+
"Loading from View uses RGB (no alpha).")
|
|
399
|
+
alpha_hint.setStyleSheet("color:#e0b000;")
|
|
400
|
+
alpha_hint.setWordWrap(True)
|
|
401
|
+
col.addWidget(alpha_hint)
|
|
402
|
+
|
|
403
|
+
# Load controls
|
|
404
|
+
row_load = QHBoxLayout()
|
|
405
|
+
b_from_view = QPushButton("Load Insert from View…"); b_from_view.clicked.connect(self._load_from_view)
|
|
406
|
+
b_from_file = QPushButton("Load Insert from File…"); b_from_file.clicked.connect(self._load_from_file)
|
|
407
|
+
row_load.addWidget(b_from_view); row_load.addWidget(b_from_file)
|
|
408
|
+
col.addLayout(row_load)
|
|
409
|
+
|
|
410
|
+
# --- Text controls ----------------------------------------------------
|
|
411
|
+
txt_grp = QGroupBox("Text")
|
|
412
|
+
tg = QGridLayout(txt_grp)
|
|
413
|
+
|
|
414
|
+
self.btn_add_text = QPushButton("Add Text…")
|
|
415
|
+
self.btn_edit_text = QPushButton("Edit Selected…"); self.btn_edit_text.setEnabled(False)
|
|
416
|
+
self.btn_add_text.clicked.connect(self._add_text_dialog)
|
|
417
|
+
self.btn_edit_text.clicked.connect(self._edit_selected_text)
|
|
418
|
+
|
|
419
|
+
tg.addWidget(self.btn_add_text, 0, 0)
|
|
420
|
+
tg.addWidget(self.btn_edit_text, 0, 1)
|
|
421
|
+
|
|
422
|
+
self.font_box = QFontComboBox(); self.font_box.setCurrentFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont))
|
|
423
|
+
self.font_size = QSpinBox(); self.font_size.setRange(4, 512); self.font_size.setValue(36)
|
|
424
|
+
self.chk_bold = QCheckBox("Bold")
|
|
425
|
+
self.chk_italic = QCheckBox("Italic")
|
|
426
|
+
|
|
427
|
+
self.btn_fill = QPushButton("Fill Color…")
|
|
428
|
+
self.btn_outline = QPushButton("Outline Color…")
|
|
429
|
+
self.outline_w = QSpinBox(); self.outline_w.setRange(0, 30); self.outline_w.setValue(0)
|
|
430
|
+
|
|
431
|
+
# wire style changes
|
|
432
|
+
self.font_box.currentFontChanged.connect(lambda _: self._apply_text_controls_to_selected())
|
|
433
|
+
self.font_size.valueChanged.connect(lambda _: self._apply_text_controls_to_selected())
|
|
434
|
+
self.chk_bold.stateChanged.connect(lambda _: self._apply_text_controls_to_selected())
|
|
435
|
+
self.chk_italic.stateChanged.connect(lambda _: self._apply_text_controls_to_selected())
|
|
436
|
+
self.btn_fill.clicked.connect(self._pick_text_fill)
|
|
437
|
+
self.btn_outline.clicked.connect(self._pick_text_outline)
|
|
438
|
+
self.outline_w.valueChanged.connect(lambda _: self._apply_text_controls_to_selected())
|
|
439
|
+
|
|
440
|
+
tg.addWidget(QLabel("Font"), 1, 0); tg.addWidget(self.font_box, 1, 1)
|
|
441
|
+
tg.addWidget(QLabel("Size"), 2, 0); tg.addWidget(self.font_size, 2, 1)
|
|
442
|
+
tg.addWidget(self.chk_bold, 3, 0); tg.addWidget(self.chk_italic, 3, 1)
|
|
443
|
+
tg.addWidget(self.btn_fill, 4, 0); tg.addWidget(self.btn_outline, 4, 1)
|
|
444
|
+
tg.addWidget(QLabel("Outline (px)"), 5, 0); tg.addWidget(self.outline_w, 5, 1)
|
|
445
|
+
|
|
446
|
+
col.addWidget(txt_grp)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Transform group
|
|
450
|
+
grp = QGroupBox("Transform")
|
|
451
|
+
g = QGridLayout(grp)
|
|
452
|
+
b_rot = QPushButton("Rotate +90°"); b_rot.clicked.connect(self._rotate_selected)
|
|
453
|
+
g.addWidget(b_rot, 0, 0, 1, 2)
|
|
454
|
+
|
|
455
|
+
g.addWidget(QLabel("Scale (%)"), 1, 0)
|
|
456
|
+
self.sl_scale = QSlider(Qt.Orientation.Horizontal); self.sl_scale.setRange(10, 400); self.sl_scale.setValue(100)
|
|
457
|
+
self.sl_scale.valueChanged.connect(self._scale_selected)
|
|
458
|
+
g.addWidget(self.sl_scale, 1, 1)
|
|
459
|
+
|
|
460
|
+
g.addWidget(QLabel("Opacity (%)"), 2, 0)
|
|
461
|
+
self.sl_opacity = QSlider(Qt.Orientation.Horizontal); self.sl_opacity.setRange(0, 100); self.sl_opacity.setValue(100)
|
|
462
|
+
self.sl_opacity.valueChanged.connect(self._opacity_selected)
|
|
463
|
+
g.addWidget(self.sl_opacity, 2, 1)
|
|
464
|
+
col.addWidget(grp)
|
|
465
|
+
|
|
466
|
+
# Bounding boxes
|
|
467
|
+
self.cb_draw = QCheckBox("Draw Bounding Box"); self.cb_draw.setChecked(True); self.cb_draw.stateChanged.connect(self._toggle_boxes)
|
|
468
|
+
col.addWidget(self.cb_draw)
|
|
469
|
+
|
|
470
|
+
grp_box = QGroupBox("Bounding Box Style")
|
|
471
|
+
gb = QGridLayout(grp_box)
|
|
472
|
+
self.b_color = QPushButton("Color…"); self.b_color.clicked.connect(self._pick_box_color)
|
|
473
|
+
self.sl_thick = QSlider(Qt.Orientation.Horizontal); self.sl_thick.setRange(1, 10); self.sl_thick.setValue(2); self.sl_thick.valueChanged.connect(self._update_box_pen)
|
|
474
|
+
self.cmb_style = QComboBox(); self.cmb_style.addItems(["Solid","Dash","Dot","DashDot","DashDotDot"]); self.cmb_style.currentIndexChanged.connect(self._update_box_pen)
|
|
475
|
+
gb.addWidget(self.b_color, 0, 0, 1, 2)
|
|
476
|
+
gb.addWidget(QLabel("Thickness"), 1, 0); gb.addWidget(self.sl_thick, 1, 1)
|
|
477
|
+
gb.addWidget(QLabel("Style"), 2, 0); gb.addWidget(self.cmb_style, 2, 1)
|
|
478
|
+
col.addWidget(grp_box)
|
|
479
|
+
|
|
480
|
+
# --- Snap with margins -------------------------------------------------
|
|
481
|
+
snap_grp = QGroupBox("Send to position")
|
|
482
|
+
sg = QGridLayout(snap_grp)
|
|
483
|
+
|
|
484
|
+
# margins
|
|
485
|
+
sg.addWidget(QLabel("Margin X (px)"), 0, 0)
|
|
486
|
+
self.sp_margin_x = QSpinBox(); self.sp_margin_x.setRange(0, 5000); self.sp_margin_x.setValue(20)
|
|
487
|
+
sg.addWidget(self.sp_margin_x, 0, 1)
|
|
488
|
+
|
|
489
|
+
sg.addWidget(QLabel("Margin Y (px)"), 0, 2)
|
|
490
|
+
self.sp_margin_y = QSpinBox(); self.sp_margin_y.setRange(0, 5000); self.sp_margin_y.setValue(20)
|
|
491
|
+
sg.addWidget(self.sp_margin_y, 0, 3)
|
|
492
|
+
|
|
493
|
+
# 3x3 snap buttons
|
|
494
|
+
def s(key): # helper to create buttons
|
|
495
|
+
btn = QPushButton(key.replace('_', ' ').title())
|
|
496
|
+
btn.setMinimumWidth(105)
|
|
497
|
+
btn.clicked.connect(lambda _, k=key: self._send_selected(k))
|
|
498
|
+
return btn
|
|
499
|
+
|
|
500
|
+
sg.addWidget(s("top_left"), 1, 0)
|
|
501
|
+
sg.addWidget(s("top_center"), 1, 1)
|
|
502
|
+
sg.addWidget(s("top_right"), 1, 2)
|
|
503
|
+
sg.addWidget(s("middle_left"), 2, 0)
|
|
504
|
+
sg.addWidget(s("center"), 2, 1)
|
|
505
|
+
sg.addWidget(s("middle_right"), 2, 2)
|
|
506
|
+
sg.addWidget(s("bottom_left"), 3, 0)
|
|
507
|
+
sg.addWidget(s("bottom_center"), 3, 1)
|
|
508
|
+
sg.addWidget(s("bottom_right"), 3, 2)
|
|
509
|
+
col.addWidget(snap_grp)
|
|
510
|
+
|
|
511
|
+
# Zoom
|
|
512
|
+
row_zoom = QHBoxLayout()
|
|
513
|
+
b_zo = QPushButton("–"); b_zo.clicked.connect(self.view.zoom_out)
|
|
514
|
+
b_zi = QPushButton("+"); b_zi.clicked.connect(self.view.zoom_in)
|
|
515
|
+
b_fit = QPushButton("Fit"); b_fit.clicked.connect(self.view.fit_to_view)
|
|
516
|
+
row_zoom.addWidget(QLabel("Zoom (Ctrl+Wheel):")); row_zoom.addWidget(b_zo); row_zoom.addWidget(b_zi); row_zoom.addWidget(b_fit); row_zoom.addStretch(1)
|
|
517
|
+
col.addLayout(row_zoom)
|
|
518
|
+
|
|
519
|
+
col.addStretch(1)
|
|
520
|
+
|
|
521
|
+
# Commit/Clear
|
|
522
|
+
row_commit = QHBoxLayout()
|
|
523
|
+
b_affix = QPushButton("Affix Inserts"); b_affix.clicked.connect(self._affix_inserts)
|
|
524
|
+
b_clear_sel = QPushButton("Clear Selected"); b_clear_sel.clicked.connect(self._clear_selected)
|
|
525
|
+
b_clear = QPushButton("Clear All"); b_clear.clicked.connect(self._clear_inserts)
|
|
526
|
+
row_commit.addWidget(b_affix)
|
|
527
|
+
row_commit.addWidget(b_clear_sel) # ← NEW
|
|
528
|
+
row_commit.addWidget(b_clear)
|
|
529
|
+
row_commit.addStretch(1)
|
|
530
|
+
col.addLayout(row_commit)
|
|
531
|
+
|
|
532
|
+
left = QWidget(); left.setLayout(col)
|
|
533
|
+
root.addWidget(left, 0)
|
|
534
|
+
root.addWidget(self.view, 1)
|
|
535
|
+
|
|
536
|
+
def _selected_text_items(self):
|
|
537
|
+
return [it for it in self.scene.selectedItems() if isinstance(it, QGraphicsTextItem)]
|
|
538
|
+
|
|
539
|
+
def _selected_pixmap_items(self):
|
|
540
|
+
return [it for it in self.scene.selectedItems() if isinstance(it, QGraphicsPixmapItem)]
|
|
541
|
+
|
|
542
|
+
def _add_text_item(self, text: str, font: QFont, color: QColor):
|
|
543
|
+
ti = OutlinedTextItem(text, font, color, outline=None, outline_w=0.0)
|
|
544
|
+
ti.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction)
|
|
545
|
+
ti.setZValue(1)
|
|
546
|
+
ti.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
|
|
547
|
+
ti.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
|
|
548
|
+
ti.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable, True)
|
|
549
|
+
ti.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
|
|
550
|
+
ti.setTransformOriginPoint(ti.boundingRect().center())
|
|
551
|
+
self.scene.addItem(ti)
|
|
552
|
+
|
|
553
|
+
TransformHandle(ti, self.scene)
|
|
554
|
+
self.text_inserts.append(ti)
|
|
555
|
+
ti.setSelected(True)
|
|
556
|
+
return ti
|
|
557
|
+
|
|
558
|
+
def _add_text_dialog(self):
|
|
559
|
+
# wrapper to match your signal connect
|
|
560
|
+
self._on_add_text()
|
|
561
|
+
|
|
562
|
+
def _add_text_dialog(self):
|
|
563
|
+
# wrapper to match your signal connect
|
|
564
|
+
self._on_add_text()
|
|
565
|
+
|
|
566
|
+
def _edit_selected_text(self):
|
|
567
|
+
items = self._selected_text_items()
|
|
568
|
+
if not items:
|
|
569
|
+
return
|
|
570
|
+
ti = items[0]
|
|
571
|
+
existing = ti.toPlainText()
|
|
572
|
+
txt, ok = QInputDialog.getMultiLineText(self, "Edit Text", "Text:", existing)
|
|
573
|
+
if ok:
|
|
574
|
+
ti.setPlainText(txt)
|
|
575
|
+
|
|
576
|
+
def _apply_text_controls_to_selected(self):
|
|
577
|
+
f = self._current_qfont()
|
|
578
|
+
w = self.outline_w.value()
|
|
579
|
+
for ti in self._selected_text_items():
|
|
580
|
+
if isinstance(ti, OutlinedTextItem):
|
|
581
|
+
ti.set_font(f)
|
|
582
|
+
# only adjust outline width here; color comes from the outline color picker
|
|
583
|
+
if w <= 0:
|
|
584
|
+
ti.set_outline(ti._outline, 0.0)
|
|
585
|
+
else:
|
|
586
|
+
ti.set_outline(ti._outline or QColor("black"), float(w))
|
|
587
|
+
else:
|
|
588
|
+
ti.setFont(f)
|
|
589
|
+
|
|
590
|
+
def _pick_text_fill(self):
|
|
591
|
+
c = QColorDialog.getColor()
|
|
592
|
+
if not c.isValid():
|
|
593
|
+
return
|
|
594
|
+
for ti in self._selected_text_items():
|
|
595
|
+
if isinstance(ti, OutlinedTextItem):
|
|
596
|
+
ti.set_fill(c)
|
|
597
|
+
else:
|
|
598
|
+
ti.setDefaultTextColor(c)
|
|
599
|
+
|
|
600
|
+
def _pick_text_outline(self):
|
|
601
|
+
c = QColorDialog.getColor()
|
|
602
|
+
if not c.isValid():
|
|
603
|
+
return
|
|
604
|
+
w = self.outline_w.value()
|
|
605
|
+
for ti in self._selected_text_items():
|
|
606
|
+
if isinstance(ti, OutlinedTextItem):
|
|
607
|
+
ti.set_outline(c, float(w))
|
|
608
|
+
|
|
609
|
+
def _clear_text_selection(self, ti: QGraphicsTextItem):
|
|
610
|
+
cur = ti.textCursor()
|
|
611
|
+
if cur.hasSelection():
|
|
612
|
+
cur.clearSelection()
|
|
613
|
+
ti.setTextCursor(cur)
|
|
614
|
+
|
|
615
|
+
def _remove_item_and_accessories(self, item: QGraphicsItem):
|
|
616
|
+
# Remove child bounding box if present & tracked
|
|
617
|
+
if isinstance(item, QGraphicsPixmapItem):
|
|
618
|
+
# child rect we added lives as parentItem(item)
|
|
619
|
+
for r in list(self.bounding_boxes):
|
|
620
|
+
if r.parentItem() is item:
|
|
621
|
+
try:
|
|
622
|
+
self.scene.removeItem(r)
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
625
|
+
self.bounding_boxes.remove(r)
|
|
626
|
+
|
|
627
|
+
# Remove any TransformHandle bound to this item
|
|
628
|
+
for it in list(self.scene.items()):
|
|
629
|
+
if isinstance(it, TransformHandle) and getattr(it, "parent_item", None) is item:
|
|
630
|
+
try:
|
|
631
|
+
self.scene.removeItem(it)
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
# Remove from our tracking lists
|
|
636
|
+
if isinstance(item, QGraphicsPixmapItem) and item in self.inserts:
|
|
637
|
+
self.inserts.remove(item)
|
|
638
|
+
if isinstance(item, QGraphicsTextItem) and item in self.text_inserts:
|
|
639
|
+
self.text_inserts.remove(item)
|
|
640
|
+
|
|
641
|
+
# Finally remove the item itself
|
|
642
|
+
try:
|
|
643
|
+
self.scene.removeItem(item)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
def _clear_selected(self):
|
|
648
|
+
for it in list(self.scene.selectedItems()):
|
|
649
|
+
# only user inserts (pixmaps) and text inserts are removable
|
|
650
|
+
if (isinstance(it, QGraphicsPixmapItem) and it in self.inserts) or isinstance(it, QGraphicsTextItem):
|
|
651
|
+
self._remove_item_and_accessories(it)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _on_selection_changed(self):
|
|
655
|
+
texts = self._selected_text_items()
|
|
656
|
+
self.btn_edit_text.setEnabled(bool(texts))
|
|
657
|
+
if texts:
|
|
658
|
+
ti = texts[0]
|
|
659
|
+
f = ti.font()
|
|
660
|
+
self.font_box.setCurrentFont(f)
|
|
661
|
+
ps = f.pointSize() if f.pointSize() > 0 else 36
|
|
662
|
+
self.font_size.setValue(int(ps))
|
|
663
|
+
self.chk_bold.setChecked(f.bold())
|
|
664
|
+
self.chk_italic.setChecked(f.italic())
|
|
665
|
+
if isinstance(ti, OutlinedTextItem):
|
|
666
|
+
self.outline_w.setValue(int(round(ti._outline_w)))
|
|
667
|
+
|
|
668
|
+
# ── NEW: when a text item becomes unselected, clear any in-text highlight
|
|
669
|
+
selected_set = set(texts)
|
|
670
|
+
for it in self.text_inserts:
|
|
671
|
+
if it not in selected_set:
|
|
672
|
+
self._clear_text_selection(it)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _current_qfont(self) -> QFont:
|
|
676
|
+
f = self.font_box.currentFont()
|
|
677
|
+
f.setPointSize(self.font_size.value())
|
|
678
|
+
f.setBold(self.chk_bold.isChecked())
|
|
679
|
+
f.setItalic(self.chk_italic.isChecked())
|
|
680
|
+
return f
|
|
681
|
+
|
|
682
|
+
def _apply_font_to_selection(self):
|
|
683
|
+
f = self._current_qfont()
|
|
684
|
+
for ti in self._selected_text_items():
|
|
685
|
+
ti.setFont(f)
|
|
686
|
+
|
|
687
|
+
def _apply_color_to_selection(self, color: QColor):
|
|
688
|
+
for ti in self._selected_text_items():
|
|
689
|
+
ti.setDefaultTextColor(color)
|
|
690
|
+
|
|
691
|
+
def _on_add_text(self):
|
|
692
|
+
txt, ok = QInputDialog.getMultiLineText(self, "Add Text", "Enter text:")
|
|
693
|
+
if not ok or not txt.strip():
|
|
694
|
+
return
|
|
695
|
+
f = self._current_qfont()
|
|
696
|
+
c = QColor("white") # default
|
|
697
|
+
ti = self._add_text_item(txt, f, c)
|
|
698
|
+
# drop it near center
|
|
699
|
+
base = next((i for i in self.scene.items()
|
|
700
|
+
if isinstance(i, QGraphicsPixmapItem) and i.zValue() == 0), None)
|
|
701
|
+
if base:
|
|
702
|
+
center_scene = base.mapToScene(base.boundingRect().center())
|
|
703
|
+
ti.setPos(center_scene - ti.boundingRect().center())
|
|
704
|
+
|
|
705
|
+
def _on_text_color(self):
|
|
706
|
+
c = QColorDialog.getColor()
|
|
707
|
+
if c.isValid():
|
|
708
|
+
self._apply_color_to_selection(c)
|
|
709
|
+
|
|
710
|
+
def _on_font_changed(self, _):
|
|
711
|
+
self._apply_font_to_selection()
|
|
712
|
+
|
|
713
|
+
def _on_font_size(self, _):
|
|
714
|
+
self._apply_font_to_selection()
|
|
715
|
+
|
|
716
|
+
def _on_font_bold(self, _):
|
|
717
|
+
self._apply_font_to_selection()
|
|
718
|
+
|
|
719
|
+
def _on_font_italic(self, _):
|
|
720
|
+
self._apply_font_to_selection()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# -------- Scene / items ----------
|
|
724
|
+
def _sync_handles(self):
|
|
725
|
+
for it in self.scene.items():
|
|
726
|
+
if isinstance(it, TransformHandle):
|
|
727
|
+
it.update_position()
|
|
728
|
+
|
|
729
|
+
def _update_base_image(self):
|
|
730
|
+
self.scene.clear()
|
|
731
|
+
arr = np.asarray(self.doc.image, dtype=np.float32)
|
|
732
|
+
if arr is None: return
|
|
733
|
+
qimg = self._numpy_to_qimage(arr)
|
|
734
|
+
bg = QGraphicsPixmapItem(QPixmap.fromImage(qimg))
|
|
735
|
+
bg.setZValue(0)
|
|
736
|
+
self.scene.addItem(bg)
|
|
737
|
+
|
|
738
|
+
def _load_from_file(self):
|
|
739
|
+
fp, _ = QFileDialog.getOpenFileName(self, "Select Insert Image", "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
|
|
740
|
+
if not fp: return
|
|
741
|
+
pm = QPixmap(fp)
|
|
742
|
+
if pm.isNull():
|
|
743
|
+
QMessageBox.warning(self, "Load Failed", "Could not load image.")
|
|
744
|
+
return
|
|
745
|
+
self._add_insert(pm)
|
|
746
|
+
|
|
747
|
+
def _load_from_view(self):
|
|
748
|
+
# list all open views via a helper the app already uses elsewhere (fallback to active only)
|
|
749
|
+
candidates = []
|
|
750
|
+
if hasattr(self.parent(), "_subwindow_docs"):
|
|
751
|
+
for title, d in self.parent()._subwindow_docs():
|
|
752
|
+
if d is self.doc: # skip self
|
|
753
|
+
continue
|
|
754
|
+
if getattr(d, "image", None) is not None:
|
|
755
|
+
candidates.append((title, d))
|
|
756
|
+
if not candidates:
|
|
757
|
+
QMessageBox.information(self, "Insert", "No other image windows found.")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
names = [t for (t, _) in candidates]
|
|
761
|
+
choice, ok = QInputDialog.getItem(self, "Load Insert from View", "Choose:", names, 0, False)
|
|
762
|
+
if not ok: return
|
|
763
|
+
d = candidates[names.index(choice)][1]
|
|
764
|
+
pm = QPixmap.fromImage(self._numpy_to_qimage(np.asarray(d.image, dtype=np.float32)))
|
|
765
|
+
self._add_insert(pm)
|
|
766
|
+
|
|
767
|
+
def _add_insert(self, pm: QPixmap):
|
|
768
|
+
it = QGraphicsPixmapItem(pm)
|
|
769
|
+
it.setFlags(
|
|
770
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
|
|
771
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
|
772
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsFocusable |
|
|
773
|
+
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
774
|
+
)
|
|
775
|
+
it.setTransformationMode(Qt.TransformationMode.SmoothTransformation)
|
|
776
|
+
it.setTransformOriginPoint(it.boundingRect().center())
|
|
777
|
+
it.setZValue(1)
|
|
778
|
+
it.setOpacity(1.0)
|
|
779
|
+
self.scene.addItem(it)
|
|
780
|
+
self.inserts.append(it)
|
|
781
|
+
TransformHandle(it, self.scene)
|
|
782
|
+
|
|
783
|
+
if self.bounding_boxes_enabled:
|
|
784
|
+
rect = QGraphicsRectItem(it.boundingRect())
|
|
785
|
+
rect.setParentItem(it)
|
|
786
|
+
rect.setPen(self.bounding_box_pen)
|
|
787
|
+
rect.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
|
|
788
|
+
rect.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresParentOpacity, True)
|
|
789
|
+
rect.setZValue(it.zValue() + 0.1)
|
|
790
|
+
self.scene.addItem(rect)
|
|
791
|
+
self.bounding_boxes.append(rect)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _send_selected(self, key: str):
|
|
795
|
+
# pixmaps
|
|
796
|
+
for it in self.inserts:
|
|
797
|
+
if it.isSelected():
|
|
798
|
+
self.send_insert_to_position(it, key)
|
|
799
|
+
# text
|
|
800
|
+
for ti in self._selected_text_items():
|
|
801
|
+
self.send_insert_to_position(ti, key)
|
|
802
|
+
|
|
803
|
+
# -------- Commands ----------
|
|
804
|
+
def _rotate_selected(self):
|
|
805
|
+
for it in self.inserts:
|
|
806
|
+
if it.isSelected():
|
|
807
|
+
it.setRotation(it.rotation() + 90)
|
|
808
|
+
|
|
809
|
+
def _scale_selected(self, val):
|
|
810
|
+
s = val / 100.0
|
|
811
|
+
for it in self.inserts:
|
|
812
|
+
if it.isSelected():
|
|
813
|
+
it.setScale(s)
|
|
814
|
+
# keep the child rect matching the pixmap's local bounds
|
|
815
|
+
for box in self.bounding_boxes:
|
|
816
|
+
if box.parentItem() == it:
|
|
817
|
+
box.setRect(it.boundingRect())
|
|
818
|
+
|
|
819
|
+
def _opacity_selected(self, val):
|
|
820
|
+
o = val / 100.0
|
|
821
|
+
for it in (self._selected_pixmap_items() + self._selected_text_items()):
|
|
822
|
+
it.setOpacity(o)
|
|
823
|
+
|
|
824
|
+
def _toggle_boxes(self, state):
|
|
825
|
+
self.bounding_boxes_enabled = bool(state)
|
|
826
|
+
for r in self.bounding_boxes:
|
|
827
|
+
r.setVisible(self.bounding_boxes_enabled)
|
|
828
|
+
|
|
829
|
+
def _pick_box_color(self):
|
|
830
|
+
c = QColorDialog.getColor()
|
|
831
|
+
if c.isValid():
|
|
832
|
+
self.bounding_box_pen.setColor(c)
|
|
833
|
+
self._refresh_all_boxes()
|
|
834
|
+
|
|
835
|
+
def _update_box_pen(self):
|
|
836
|
+
style_map = {
|
|
837
|
+
"Solid": Qt.PenStyle.SolidLine,
|
|
838
|
+
"Dash": Qt.PenStyle.DashLine,
|
|
839
|
+
"Dot": Qt.PenStyle.DotLine,
|
|
840
|
+
"DashDot": Qt.PenStyle.DashDotLine,
|
|
841
|
+
"DashDotDot": Qt.PenStyle.DashDotDotLine
|
|
842
|
+
}
|
|
843
|
+
self.bounding_box_pen.setWidth(self.sl_thick.value())
|
|
844
|
+
self.bounding_box_pen.setStyle(style_map[self.cmb_style.currentText()])
|
|
845
|
+
self._refresh_all_boxes()
|
|
846
|
+
|
|
847
|
+
def _refresh_all_boxes(self):
|
|
848
|
+
for r in self.bounding_boxes:
|
|
849
|
+
r.setPen(self.bounding_box_pen)
|
|
850
|
+
|
|
851
|
+
# snap an insert to one of 9 standard positions inside the base image
|
|
852
|
+
def send_insert_to_position(self, item: QGraphicsItem, key: str):
|
|
853
|
+
"""Snap a selected insert (pixmap or text) to one of 9 standard positions."""
|
|
854
|
+
base = next((i for i in self.scene.items()
|
|
855
|
+
if isinstance(i, QGraphicsPixmapItem) and i.zValue() == 0), None)
|
|
856
|
+
if not base:
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
mx = self.sp_margin_x.value()
|
|
860
|
+
my = self.sp_margin_y.value()
|
|
861
|
+
|
|
862
|
+
br = base.boundingRect()
|
|
863
|
+
# item's *local* bounding rect
|
|
864
|
+
ir = item.boundingRect()
|
|
865
|
+
size = ir.size()
|
|
866
|
+
|
|
867
|
+
table = {
|
|
868
|
+
"top_left": QPointF(br.left() + mx, br.top() + my),
|
|
869
|
+
"top_center": QPointF(br.center().x() - size.width()/2, br.top() + my),
|
|
870
|
+
"top_right": QPointF(br.right() - size.width() - mx, br.top() + my),
|
|
871
|
+
"middle_left": QPointF(br.left() + mx, br.center().y() - size.height()/2),
|
|
872
|
+
"center": QPointF(br.center().x() - size.width()/2, br.center().y() - size.height()/2),
|
|
873
|
+
"middle_right": QPointF(br.right() - size.width() - mx, br.center().y() - size.height()/2),
|
|
874
|
+
"bottom_left": QPointF(br.left() + mx, br.bottom() - size.height() - my),
|
|
875
|
+
"bottom_center": QPointF(br.center().x() - size.width()/2, br.bottom() - size.height() - my),
|
|
876
|
+
"bottom_right": QPointF(br.right() - size.width() - mx, br.bottom() - size.height() - my),
|
|
877
|
+
}
|
|
878
|
+
pt = table.get(key)
|
|
879
|
+
if pt is None:
|
|
880
|
+
return
|
|
881
|
+
|
|
882
|
+
# map the desired *base* point into scene coords, then move item so its local
|
|
883
|
+
# top-left (0,0) maps onto that scene point.
|
|
884
|
+
scene_pt = base.mapToScene(pt)
|
|
885
|
+
item.setPos(scene_pt)
|
|
886
|
+
|
|
887
|
+
def _scrub_text_highlights_for_render(self):
|
|
888
|
+
"""
|
|
889
|
+
Collapse any QTextCursor selections inside QGraphicsTextItem so no
|
|
890
|
+
character-range highlight can be painted by Qt during scene.render().
|
|
891
|
+
Also disable editing and selection temporarily to be extra safe.
|
|
892
|
+
"""
|
|
893
|
+
self._text_restore = [] # stash state to restore after render
|
|
894
|
+
|
|
895
|
+
for it in self.scene.items():
|
|
896
|
+
if isinstance(it, QGraphicsTextItem):
|
|
897
|
+
# Save the minimal state we need to restore
|
|
898
|
+
self._text_restore.append((
|
|
899
|
+
it,
|
|
900
|
+
it.textInteractionFlags(),
|
|
901
|
+
it.flags(),
|
|
902
|
+
it.hasFocus()
|
|
903
|
+
))
|
|
904
|
+
|
|
905
|
+
# 1) Collapse any in-text selection (this is the blue 'N' you see)
|
|
906
|
+
cur = it.textCursor()
|
|
907
|
+
# Force a definite collapse (some cases cur.hasSelection() is False
|
|
908
|
+
# but an anchor remains; resetting both positions removes it):
|
|
909
|
+
pos = cur.position()
|
|
910
|
+
cur.setPosition(pos, QTextCursor.MoveMode.MoveAnchor)
|
|
911
|
+
cur.setPosition(pos, QTextCursor.MoveMode.KeepAnchor) # set, then collapse
|
|
912
|
+
cur.clearSelection()
|
|
913
|
+
it.setTextCursor(cur)
|
|
914
|
+
|
|
915
|
+
# 2) Fully exit editing state
|
|
916
|
+
it.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
|
917
|
+
it.clearFocus()
|
|
918
|
+
|
|
919
|
+
# 3) Make sure the item itself cannot be “selected” while we paint
|
|
920
|
+
it.setSelected(False)
|
|
921
|
+
it.setFlags(it.flags() & ~QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
|
922
|
+
|
|
923
|
+
# 4) Ensure a repaint with the new state
|
|
924
|
+
it.update()
|
|
925
|
+
|
|
926
|
+
def _restore_text_state_after_render(self):
|
|
927
|
+
if not hasattr(self, "_text_restore"):
|
|
928
|
+
return
|
|
929
|
+
for it, flags, item_flags, had_focus in self._text_restore:
|
|
930
|
+
it.setTextInteractionFlags(flags)
|
|
931
|
+
it.setFlags(item_flags)
|
|
932
|
+
if had_focus:
|
|
933
|
+
it.setFocus()
|
|
934
|
+
self._text_restore = []
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# bake overlays into the doc
|
|
938
|
+
def _affix_inserts(self):
|
|
939
|
+
if not (self.inserts or self._selected_text_items() or any(isinstance(i, QGraphicsTextItem) for i in self.scene.items())):
|
|
940
|
+
QMessageBox.information(self, "Signature / Insert", "Nothing to affix.")
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
# Deselect everything to avoid selection outlines in the render
|
|
944
|
+
for it in self.scene.selectedItems():
|
|
945
|
+
it.setSelected(False)
|
|
946
|
+
|
|
947
|
+
# honor box visibility
|
|
948
|
+
hidden_boxes = []
|
|
949
|
+
if not self.bounding_boxes_enabled:
|
|
950
|
+
for r in self.bounding_boxes:
|
|
951
|
+
r.setVisible(False); hidden_boxes.append(r)
|
|
952
|
+
|
|
953
|
+
# gather background + pixmap inserts + text + (maybe) boxes
|
|
954
|
+
items = []
|
|
955
|
+
for it in self.scene.items():
|
|
956
|
+
if isinstance(it, QGraphicsPixmapItem) and it.zValue() == 0:
|
|
957
|
+
items.append(it) # background
|
|
958
|
+
elif isinstance(it, QGraphicsPixmapItem) and it in self.inserts:
|
|
959
|
+
items.append(it)
|
|
960
|
+
elif isinstance(it, QGraphicsTextItem):
|
|
961
|
+
items.append(it)
|
|
962
|
+
elif self.bounding_boxes_enabled and isinstance(it, QGraphicsRectItem):
|
|
963
|
+
items.append(it)
|
|
964
|
+
|
|
965
|
+
# compute scene bbox
|
|
966
|
+
bbox = QRectF()
|
|
967
|
+
for it in items:
|
|
968
|
+
bbox = bbox.united(it.sceneBoundingRect())
|
|
969
|
+
bbox = bbox.normalized()
|
|
970
|
+
x, y = int(bbox.left()), int(bbox.top())
|
|
971
|
+
w, h = int(bbox.right()) - x, int(bbox.bottom()) - y
|
|
972
|
+
if w <= 0 or h <= 0:
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
# Temporarily suppress in-text selection highlights for text items
|
|
976
|
+
text_states = []
|
|
977
|
+
for it in self.scene.items():
|
|
978
|
+
if isinstance(it, QGraphicsTextItem):
|
|
979
|
+
text_states.append((
|
|
980
|
+
it,
|
|
981
|
+
it.textInteractionFlags(),
|
|
982
|
+
it.textCursor(),
|
|
983
|
+
it.hasFocus()
|
|
984
|
+
))
|
|
985
|
+
# clear any selection highlight and disable editing visuals
|
|
986
|
+
cur = it.textCursor()
|
|
987
|
+
if cur.hasSelection():
|
|
988
|
+
cur.clearSelection()
|
|
989
|
+
it.setTextCursor(cur)
|
|
990
|
+
it.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
|
991
|
+
it.clearFocus()
|
|
992
|
+
|
|
993
|
+
# temporarily hide non-items
|
|
994
|
+
hidden = []
|
|
995
|
+
for it in self.scene.items():
|
|
996
|
+
if it not in items:
|
|
997
|
+
it.setVisible(False); hidden.append(it)
|
|
998
|
+
|
|
999
|
+
self._scrub_text_highlights_for_render()
|
|
1000
|
+
|
|
1001
|
+
# --- render ---
|
|
1002
|
+
out = QImage(w, h, QImage.Format.Format_ARGB32)
|
|
1003
|
+
out.fill(Qt.GlobalColor.transparent)
|
|
1004
|
+
p = QPainter(out)
|
|
1005
|
+
self.scene.render(p, target=QRectF(0, 0, w, h), source=QRectF(x, y, w, h))
|
|
1006
|
+
p.end()
|
|
1007
|
+
|
|
1008
|
+
self._restore_text_state_after_render()
|
|
1009
|
+
|
|
1010
|
+
# restore hidden things
|
|
1011
|
+
for it in hidden: it.setVisible(True)
|
|
1012
|
+
for r in hidden_boxes: r.setVisible(True)
|
|
1013
|
+
|
|
1014
|
+
# restore text editability / state
|
|
1015
|
+
for it, flags, cursor, had_focus in text_states:
|
|
1016
|
+
it.setTextInteractionFlags(flags)
|
|
1017
|
+
it.setTextCursor(cursor)
|
|
1018
|
+
if had_focus:
|
|
1019
|
+
it.setFocus()
|
|
1020
|
+
|
|
1021
|
+
# temporarily hide non-items
|
|
1022
|
+
hidden = []
|
|
1023
|
+
for it in self.scene.items():
|
|
1024
|
+
if it not in items:
|
|
1025
|
+
it.setVisible(False); hidden.append(it)
|
|
1026
|
+
|
|
1027
|
+
# render
|
|
1028
|
+
out = QImage(w, h, QImage.Format.Format_ARGB32)
|
|
1029
|
+
out.fill(Qt.GlobalColor.transparent)
|
|
1030
|
+
p = QPainter(out)
|
|
1031
|
+
self.scene.render(p, target=QRectF(0, 0, w, h), source=QRectF(x, y, w, h))
|
|
1032
|
+
p.end()
|
|
1033
|
+
|
|
1034
|
+
# restore
|
|
1035
|
+
for it in hidden: it.setVisible(True)
|
|
1036
|
+
for r in hidden_boxes: r.setVisible(True)
|
|
1037
|
+
|
|
1038
|
+
# drop alpha → RGB, write back to doc
|
|
1039
|
+
arr = self._qimage_to_numpy(out)
|
|
1040
|
+
if arr.shape[2] == 4:
|
|
1041
|
+
arr = arr[:, :, :3]
|
|
1042
|
+
arr = np.clip(arr, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1043
|
+
|
|
1044
|
+
if hasattr(self.doc, "set_image"):
|
|
1045
|
+
self.doc.set_image(arr, step_name="Signature / Insert")
|
|
1046
|
+
elif hasattr(self.doc, "apply_numpy"):
|
|
1047
|
+
self.doc.apply_numpy(arr, step_name="Signature / Insert")
|
|
1048
|
+
else:
|
|
1049
|
+
self.doc.image = arr
|
|
1050
|
+
|
|
1051
|
+
# cleanup
|
|
1052
|
+
self._clear_inserts()
|
|
1053
|
+
self._update_base_image()
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _clear_inserts(self):
|
|
1057
|
+
# remove all user pixmap inserts
|
|
1058
|
+
for it in list(self.inserts):
|
|
1059
|
+
self._remove_item_and_accessories(it)
|
|
1060
|
+
self.inserts.clear()
|
|
1061
|
+
|
|
1062
|
+
# remove all text inserts
|
|
1063
|
+
for ti in list(self.text_inserts):
|
|
1064
|
+
self._remove_item_and_accessories(ti)
|
|
1065
|
+
self.text_inserts.clear()
|
|
1066
|
+
|
|
1067
|
+
# any stray boxes that weren't parented/cleaned
|
|
1068
|
+
for r in list(self.bounding_boxes):
|
|
1069
|
+
try:
|
|
1070
|
+
self.scene.removeItem(r)
|
|
1071
|
+
except Exception:
|
|
1072
|
+
pass
|
|
1073
|
+
self.bounding_boxes.clear()
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
# ------------------ numpy/QImage bridges ------------------
|
|
1077
|
+
def _numpy_to_qimage(self, a: np.ndarray) -> QImage:
|
|
1078
|
+
a = np.asarray(a, dtype=np.float32)
|
|
1079
|
+
a = np.clip(a, 0.0, 1.0)
|
|
1080
|
+
if a.ndim == 2:
|
|
1081
|
+
a = a[..., None].repeat(3, axis=2)
|
|
1082
|
+
if a.shape[2] == 3:
|
|
1083
|
+
fmt, ch = QImage.Format.Format_RGB888, 3
|
|
1084
|
+
elif a.shape[2] == 4:
|
|
1085
|
+
fmt, ch = QImage.Format.Format_RGBA8888, 4
|
|
1086
|
+
else:
|
|
1087
|
+
raise ValueError(f"Unsupported shape {a.shape}")
|
|
1088
|
+
u8 = (a * 255.0).astype(np.uint8)
|
|
1089
|
+
u8 = np.ascontiguousarray(u8)
|
|
1090
|
+
h, w = u8.shape[:2]
|
|
1091
|
+
return QImage(u8.data, w, h, w*ch, fmt).copy()
|
|
1092
|
+
|
|
1093
|
+
def _qimage_to_numpy(self, img: QImage) -> np.ndarray:
|
|
1094
|
+
q = img.convertToFormat(QImage.Format.Format_RGBA8888)
|
|
1095
|
+
w, h = q.width(), q.height()
|
|
1096
|
+
ptr = q.bits(); ptr.setsize(h * q.bytesPerLine())
|
|
1097
|
+
buf = np.frombuffer(ptr, dtype=np.uint8).reshape((h, q.bytesPerLine()))
|
|
1098
|
+
arr = buf[:, :w*4].reshape((h, w, 4)).astype(np.float32)/255.0
|
|
1099
|
+
return arr
|