Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,30 +208,25 @@
"de": "Bild laden (oder per Drag & Drop)", "es": "Cargar imagen (o arrastrar y soltar)",
},
"img.delete": {
"fr": "🗑 Supprimer l'image", "en": "🗑 Delete Image",
"de": "🗑 Bild löschen", "es": "🗑 Eliminar imagen",
"fr": "Supprimer l'image", "en": "Delete Image",
"de": "Bild löschen", "es": "Eliminar imagen",
},
"img.rotation": {"fr": "Rotation:", "en": "Rotation:", "de": "Drehung:", "es": "Rotación:"},
"img.rotate_left": {
"fr": "↶ Pivoter 90°", "en": "↶ Rotate 90°",
"de": "↶ Um 90° drehen", "es": "↶ Girar 90°",
"fr": "Pivoter l'image de 90° vers la gauche", "en": "Rotate Image 90° Left",
"de": "Bild um 90° nach links drehen", "es": "Girar la imagen 90° a la izquierda",
},
"img.rotate_right": {
"fr": "Pivoter 90° ↷", "en": "Rotate 90° ↷",
"de": "Um 90° drehen ↷", "es": "Girar 90° ↷",
"fr": "Pivoter l'image de 90° vers la droite", "en": "Rotate Image 90° Right",
"de": "Bild um 90° nach rechts drehen", "es": "Girar la imagen 90° a la derecha",
},
"img.mirror_h": {
"fr": "Miroir Horizontal", "en": "Horizontal Mirror",
"fr": "Miroir horizontal", "en": "Horizontal Mirror",
"de": "Horizontal spiegeln", "es": "Espejo horizontal",
},
"img.mirror_v": {
"fr": "Miroir Vertical", "en": "Vertical Mirror",
"fr": "Miroir vertical", "en": "Vertical Mirror",
"de": "Vertikal spiegeln", "es": "Espejo vertical",
},
"img.mirror_mode": {
"fr": "Mode Miroir:", "en": "Mirror Mode:",
"de": "Spiegelmodus:", "es": "Modo espejo:",
},
"img.invert": {
"fr": "Inverser Couleurs (Négatif)", "en": "Invert Colors (Negative)",
"de": "Farben invertieren (Negativ)", "es": "Invertir colores (Negativo)",
Expand Down
19 changes: 11 additions & 8 deletions image_processing_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def reset_image_settings(self):
self.combo_material_bg.setCurrentIndex(self.combo_material_bg.findData("light"))
self.combo_material_bg.blockSignals(False)
self.chk_invert.setChecked(False)
self.chk_mirror_h.setChecked(False)
self.chk_mirror_v.setChecked(False)
self.action_mirror_h.setChecked(False)
self.action_mirror_v.setChecked(False)

def get_material_bg(self):
"""Matière choisie : "dark" (fond noir) ou "light" (fond clair)."""
Expand Down Expand Up @@ -291,6 +291,7 @@ def clear_image(self):
self.processed_array = None
self.view_preview_src.clear_image()
self.view_preview_dither.clear_image()
self.update_image_actions_state()
self.txt_console.append(tr("image.deleted"))

def load_image_from_path(self, path):
Expand Down Expand Up @@ -357,6 +358,8 @@ def display_source_image(self):
img = self.raw_image.copy()
qimg = QImage(img.tobytes(), img.width, img.height, img.width, QImage.Format.Format_Grayscale8)
self.view_preview_src.set_image(qimg)
# Chargement par fichier, projet ou onglet Séparation : tous passent par ici.
self.update_image_actions_state()

def _capture_image_settings_snapshot(self):
"""État des réglages de tramage proprement dits (pas les dimensions
Expand All @@ -369,8 +372,8 @@ def _capture_image_settings_snapshot(self):
"sharp": self.slider_sharp.value(),
"invert": self.chk_invert.isChecked(),
"material_bg": self.get_material_bg(),
"mirror_h": self.chk_mirror_h.isChecked(),
"mirror_v": self.chk_mirror_v.isChecked(),
"mirror_h": self.action_mirror_h.isChecked(),
"mirror_v": self.action_mirror_v.isChecked(),
"algo": self.combo_algo.currentText(),
}

Expand All @@ -387,8 +390,8 @@ def _restore_image_settings_snapshot(self, snap):
self.set_material_bg(snap.get("material_bg", "light"))
for chk, key in (
(self.chk_invert, "invert"),
(self.chk_mirror_h, "mirror_h"),
(self.chk_mirror_v, "mirror_v"),
(self.action_mirror_h, "mirror_h"),
(self.action_mirror_v, "mirror_v"),
):
chk.blockSignals(True)
chk.setChecked(snap[key])
Expand Down Expand Up @@ -460,8 +463,8 @@ def _do_update_image_processing(self):
'w_mm': self.spin_w.value(),
'h_mm': self.spin_h.value(),
'lmm': self.spin_lmm.value(),
'mirror_h': self.chk_mirror_h.isChecked(),
'mirror_v': self.chk_mirror_v.isChecked(),
'mirror_h': self.action_mirror_h.isChecked(),
'mirror_v': self.action_mirror_v.isChecked(),
'bright': self.slider_bright.value(),
'contrast': self.slider_contrast.value(),
'gamma': self.slider_gamma.value(),
Expand Down
1 change: 1 addition & 0 deletions png2svg_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def _init_png2svg_tab(self):

self.png2svg_widget = PngToSvgWidget()
self.png2svg_widget.sendToVectorEditor.connect(self._on_png2svg_send_to_vector)
self.png2svg_widget.imageChanged.connect(self.update_image_actions_state)
layout.addWidget(self.png2svg_widget)

return self.tab_png2svg
Expand Down
29 changes: 28 additions & 1 deletion png2svg_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import tempfile

from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QByteArray, QSettings, QRectF
from PyQt6.QtGui import QPixmap, QPainter, QImage, QShortcut, QKeySequence
from PyQt6.QtGui import QPixmap, QPainter, QImage, QShortcut, QKeySequence, QTransform
from PyQt6.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton, QSlider,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QVBoxLayout, QHBoxLayout,
Expand Down Expand Up @@ -64,6 +64,12 @@ def load_svg_bytes(self, svg_bytes: bytes):
if was_empty:
self.zoom_fit()

def clear(self):
"""Vide l'aperçu (même nom que QLabel.clear, utilisé quand QtSvgWidgets manque)."""
self._renderer = QSvgRenderer()
self._svg_item.setSharedRenderer(self._renderer)
self._has_content = False

def zoom_fit(self):
if self._scene.sceneRect().isEmpty():
return
Expand All @@ -88,6 +94,7 @@ class PngToSvgWidget(QWidget):

svgGenerated = pyqtSignal(str) # émis à chaque régénération réussie du SVG
sendToVectorEditor = pyqtSignal(str) # émis quand l'utilisateur clique "Envoyer vers l'Éditeur Vectoriel"
imageChanged = pyqtSignal() # émis quand une image est chargée ou retirée

def __init__(self, parent=None):
super().__init__(parent)
Expand Down Expand Up @@ -440,6 +447,26 @@ def _load_image_path(self, path):
)
self.btn_export.setEnabled(True)
self._regenerate()
self.imageChanged.emit()

def rotate_image(self, angle):
"""Pivote l'image chargée de `angle` degrés (positif = sens horaire) et relance la
conversion. L'image pivotée passe par un fichier temporaire, comme un collage."""
if not self.image_path:
return
self._load_image_from_qimage(QImage(self.image_path).transformed(QTransform().rotate(angle)))

def clear_image(self):
"""Retire l'image chargée et l'aperçu SVG."""
self._debounce.stop()
self.image_path = None
self.last_result = None
self.original_view.clear()
self.svg_view.clear()
self.btn_export.setEnabled(False)
self.btn_send_vector.setEnabled(False)
self.status_label.setText(tr("png2svg.no_image"))
self.imageChanged.emit()

def _load_image_from_qimage(self, qimage: QImage):
"""Reçoit une image déjà en mémoire (glisser-déposer d'une image
Expand Down
8 changes: 4 additions & 4 deletions project_io_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def _get_project_data(self, encode_image=True):
"safe_cut_order": self.chk_safe_cut_order.isChecked(),
"invert": self.chk_invert.isChecked(),
"material_bg": self.get_material_bg(),
"mirror_h": self.chk_mirror_h.isChecked(),
"mirror_v": self.chk_mirror_v.isChecked(),
"mirror_h": self.action_mirror_h.isChecked(),
"mirror_v": self.action_mirror_v.isChecked(),
"algo": self.combo_algo.currentText(),
"lmm": self.spin_lmm.value(),
"speed_engrave": self.spin_speed_engrave.value(),
Expand Down Expand Up @@ -130,8 +130,8 @@ def _load_project_from_path(self, path):
# Anciens projets sans "material_bg" : déduit du négatif sauvegardé.
self.set_material_bg(proj_data.get("material_bg", "dark" if proj_data.get("invert", False) else "light"))
self.chk_invert.setChecked(proj_data.get("invert", False))
self.chk_mirror_h.setChecked(proj_data.get("mirror_h", False))
self.chk_mirror_v.setChecked(proj_data.get("mirror_v", False))
self.action_mirror_h.setChecked(proj_data.get("mirror_h", False))
self.action_mirror_v.setChecked(proj_data.get("mirror_v", False))
self.combo_algo.setCurrentText(proj_data.get("algo", "Floyd-Steinberg"))
self.spin_lmm.setValue(proj_data.get("lmm", 10.0))

Expand Down
49 changes: 43 additions & 6 deletions separation_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,43 @@ def separation_load_path(self, path):
if self.sep_keep_ratio.isChecked():
self._sep_apply_ratio("w")
self.separation_push_tone()
self.update_image_actions_state()

def separation_rotate_image(self, angle):
"""Pivote l'image source de `angle` degrés (positif = sens antihoraire, comme PIL)
et échange la largeur et la hauteur de la plaque."""
if self.sep_src is None:
return
self.sep_src = self.sep_src.rotate(angle, expand=True)
small = self.sep_src.copy()
small.thumbnail((2500, 2500))
self.sep_src_small = small
self.sep_version += 1
self._sep_block = True
try:
w_mm, h_mm = self.sep_w.value(), self.sep_h.value()
self.sep_w.setValue(h_mm)
self.sep_h.setValue(w_mm)
finally:
self._sep_block = False
self.separation_push_tone()

def separation_clear_image(self):
"""Retire l'image source et vide les aperçus. L'image déjà envoyée à
Image & Filtres y reste (elle se supprime depuis cet onglet)."""
self.sep_push_timer.stop()
self.sep_preview_timer.stop()
self.sep_src = None
self.sep_src_small = None
self.sep_path = ""
self.sep_version += 1
self._sep_cache = None
self._sep_stale = set()
self.sep_lbl_file.setText(tr("sep.no_image_loaded"))
for view in self.sep_views:
view.clear_image()
self._sep_view_sizes = {}
self.update_image_actions_state()

def _sep_apply_glass_settings(self):
"""Applique matière « fond noir » + négatif + miroir horizontal.
Expand All @@ -380,13 +417,13 @@ def _sep_apply_glass_settings(self):
self._sep_prev_image_settings = {
"material": self.get_material_bg() if hasattr(self, "get_material_bg") else None,
"invert": self.chk_invert.isChecked(),
"mirror_h": self.chk_mirror_h.isChecked(),
"mirror_h": self.action_mirror_h.isChecked(),
}
self._sep_glass_settings_active = True
if hasattr(self, "set_material_bg"):
self.set_material_bg("dark") # ne touche pas à la case, réglée juste après
self.chk_invert.setChecked(True)
self.chk_mirror_h.setChecked(True)
self.action_mirror_h.setChecked(True)

def separation_release_image_settings(self):
"""Appelé quand une image est chargée côté Images Tramage : rend les
Expand All @@ -401,7 +438,7 @@ def separation_release_image_settings(self):
if prev.get("material") and hasattr(self, "set_material_bg"):
self.set_material_bg(prev["material"])
self.chk_invert.setChecked(prev["invert"])
self.chk_mirror_h.setChecked(prev["mirror_h"])
self.action_mirror_h.setChecked(prev["mirror_h"])

def separation_push_tone(self):
"""Envoie la tonalité (canal V, cadrée sur la plaque) à Image & Filtres
Expand Down Expand Up @@ -524,9 +561,9 @@ def _sep_render_current(self):
t_b = time.perf_counter()
grav = Image.fromarray(arr).resize((pw, ph), Image.BOX)
lum = (255.0 - np.asarray(grav, dtype=np.float32)) / 255.0
if self.chk_mirror_h.isChecked():
if self.action_mirror_h.isChecked():
lum = lum[:, ::-1]
if self.chk_mirror_v.isChecked():
if self.action_mirror_v.isChecked():
lum = lum[::-1, :]
lum = np.ascontiguousarray(lum)
dpi_prev = pw / (self.sep_w.value() / 25.4)
Expand Down Expand Up @@ -567,7 +604,7 @@ def separation_gain_test(self):
la résolution, la vitesse, la puissance et la machine actuelles. Une fois
gravée, on repère la ligne dont les cases passent régulièrement du noir
au clair, et on règle le curseur sur la valeur écrite à gauche."""
mirror = self.chk_mirror_h.isChecked()
mirror = self.action_mirror_h.isChecked()
arr, w_mm, h_mm = build_gain_test_array(self.spin_lmm.value(), mirror=mirror)
bed_w, bed_h = self.spin_machine_w.value(), self.spin_machine_h.value()
if w_mm > bed_w or h_mm > bed_h:
Expand Down
58 changes: 58 additions & 0 deletions tests/test_settings_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ def added_to(menu, method, key):
added_to("self.tools_menu", "addAction", "menu.tools.stencil_studio")
self.assertNotIn("menu_bar.addAction(", body)

def test_image_actions_are_in_the_toolbar_not_the_image_tab(self):
src = read("ui_setup_mixin.py")
toolbar = src[src.index("def _build_main_toolbar"):src.index("def _on_language_selected")]
self.assertIn("self._on_toolbar_delete_image", toolbar)
self.assertEqual(toolbar.count("self._on_toolbar_rotate_image("), 2)
for name in ("action_mirror_h", "action_mirror_v"):
self.assertIn(f"self.{name} = toolbar.addAction(", toolbar)
# Plus aucun bouton dans l'onglet Image & Filtres : seul l'aiguillage les appelle.
for call in ("self.rotate_image_left", "self.rotate_image_right", "self.clear_image()"):
self.assertEqual(src.count(call), 1, call)
self.assertNotIn("chk_mirror", src)

def test_pages_are_no_longer_tabs_of_the_left_column(self):
src = read("ui_setup_mixin.py")
self.assertNotIn("tabs.addTab(tab_mach,", src)
Expand Down Expand Up @@ -115,6 +127,52 @@ def test_all_four_menu_windows_are_wired_to_their_pages(self):
self.assertIs(pages["machine_params"], obj.tab_machine_params)
self.assertIs(pages["test_matrix"], obj.tab_matrix)

def image_tabs(self):
obj = self.make()
obj._image_actions = (mock.Mock(), mock.Mock(), mock.Mock())
obj._mirror_actions = (mock.Mock(), mock.Mock())
obj.tab_previews, obj.tab_png2svg, obj.tab_separation = object(), object(), object()
obj.main_tabs_view = mock.Mock()
obj.raw_image = obj.sep_src = None
obj.png2svg_widget = mock.Mock(image_path=None)
return obj

def assert_enabled(self, obj, tab, image_actions, mirrors):
obj.main_tabs_view.currentWidget.return_value = tab
obj.update_image_actions_state()
for action in obj._image_actions:
action.setEnabled.assert_called_with(image_actions)
for action in obj._mirror_actions:
action.setEnabled.assert_called_with(mirrors)

def test_image_actions_need_an_image_on_the_current_tab(self):
obj = self.image_tabs()
for tab in (obj.tab_previews, obj.tab_png2svg, obj.tab_separation, object()):
self.assert_enabled(obj, tab, False, False)
obj.raw_image = "img"
self.assert_enabled(obj, obj.tab_previews, True, True)
self.assert_enabled(obj, obj.tab_separation, False, False) # l'image d'un autre onglet ne compte pas
self.assert_enabled(obj, object(), False, False)
obj.sep_src = "img"
self.assert_enabled(obj, obj.tab_separation, True, True)
obj.png2svg_widget.image_path = "/tmp/a.png"
self.assert_enabled(obj, obj.tab_png2svg, True, False) # pas de miroir de gravure en PNG ⇒ SVG

def test_toolbar_rotation_targets_the_current_tab(self):
obj = self.image_tabs()
obj.rotate_image_left, obj.rotate_image_right = mock.Mock(), mock.Mock()
obj.separation_rotate_image = mock.Mock()
obj.main_tabs_view.currentWidget.return_value = obj.tab_previews
obj._on_toolbar_rotate_image(clockwise=True)
obj.rotate_image_right.assert_called_once()
obj.main_tabs_view.currentWidget.return_value = obj.tab_png2svg
obj._on_toolbar_rotate_image(clockwise=True)
obj.png2svg_widget.rotate_image.assert_called_once_with(90)
obj.main_tabs_view.currentWidget.return_value = obj.tab_separation
obj._on_toolbar_rotate_image(clockwise=True)
obj.separation_rotate_image.assert_called_once_with(-90) # PIL : positif = antihoraire
obj.rotate_image_left.assert_not_called()

def test_missing_page_is_ignored(self):
self.Recorder.created = []
obj = self.make(with_pages=False)
Expand Down
Loading
Loading