# -*- coding: utf-8 -*- """ 이미지 뷰어/편집기 다이얼로그 모듈 이미지를 확대/축소하고 편집할 수 있는 다이얼로그입니다. 기능: - 마우스 휠: 이전/다음 이미지 - Ctrl + 마우스 휠: 확대/축소 (마우스 커서 중심) - 드래그: 이미지 이동 (확대 시) / 도형 그리기 (편집 모드) - 더블클릭: 원본 크기로 보기/맞춤 - 도형: 사각형, 원, 선, 화살표 - 텍스트: 텍스트 박스 추가 - 효과: 색상, 두께, 그림자 - Ctrl+Z: 실행 취소 (Undo) - Ctrl+Y: 다시 실행 (Redo) - 꼭지점 드래그로 도형 크기 조절 """ from typing import List, Optional, Dict, Any, Tuple from enum import Enum from dataclasses import dataclass from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsTextItem, QGraphicsPolygonItem, QGraphicsItem, QGraphicsDropShadowEffect, QPushButton, QToolButton, QColorDialog, QSpinBox, QApplication, QButtonGroup, QFrame, QInputDialog, QMessageBox, QMenu, QComboBox, QGraphicsItemGroup ) from PySide6.QtCore import Qt, QPointF, Signal, QRectF, QLineF from PySide6.QtGui import ( QPixmap, QWheelEvent, QMouseEvent, QPainter, QKeyEvent, QColor, QPen, QBrush, QFont, QPolygonF ) # 지원 폰트 목록 (지마켓산스 + 공공기관/무료 웹폰트) AVAILABLE_FONTS = [ "GmarketSansMedium", # 지마켓산스 "Pretendard", # 프리텐다드 (가장 인기) "Noto Sans KR", # 구글 노토산스 "Spoqa Han Sans Neo", # 스포카한산스 "SUIT", # SUIT 폰트 "Wanted Sans", # 원티드산스 "Nanum Gothic", # 네이버 나눔고딕 "Nanum Myeongjo", # 네이버 나눔명조 "KoPub Dotum", # 문체부 KoPub돋움 "IBM Plex Sans KR", # IBM 플렉스 "맑은 고딕", # 시스템 기본 ] from services.storage_service import Attachment from core.logger import get_logger logger = get_logger(__name__) # 커서 상수 CURSOR_DEFAULT = Qt.ArrowCursor CURSOR_POINT = Qt.PointingHandCursor CURSOR_DRAW = Qt.CrossCursor CURSOR_MOVE = Qt.ClosedHandCursor CURSOR_GRAB = Qt.OpenHandCursor CURSOR_SIZE_ALL = Qt.SizeAllCursor # 버텍스 감지 거리 VERTEX_EPSILON = 10.0 class EditTool(Enum): """편집 도구""" SELECT = "select" RECT = "rect" ELLIPSE = "ellipse" LINE = "line" ARROW = "arrow" TEXT = "text" FREEHAND = "freehand" class OutlineTextItem(QGraphicsTextItem): """돋보이기(외곽선) 효과가 적용된 텍스트 아이템 8방향으로 외곽선 색상을 렌더링하여 배경에 상관없이 글자가 잘 보이도록 합니다. """ def __init__(self, text: str = "", parent=None): super().__init__(text, parent) self._outline_color: Optional[QColor] = None self._outline_offset = 2 # 외곽선 두께 def set_outline_color(self, color: Optional[QColor]): """외곽선 색상 설정""" self._outline_color = color self.update() def get_outline_color(self) -> Optional[QColor]: """외곽선 색상 반환""" return self._outline_color def paint(self, painter: QPainter, option, widget=None): """페인트 이벤트 - 8방향 외곽선 렌더링""" # 외곽선 효과가 있으면 8방향으로 렌더링 if self._outline_color: painter.save() # 원래 색상 저장 original_color = self.defaultTextColor() # 8방향 오프셋 offsets = [ (-self._outline_offset, -self._outline_offset), (0, -self._outline_offset), (self._outline_offset, -self._outline_offset), (-self._outline_offset, 0), (self._outline_offset, 0), (-self._outline_offset, self._outline_offset), (0, self._outline_offset), (self._outline_offset, self._outline_offset), ] # 외곽선 색상으로 8방향 렌더링 self.setDefaultTextColor(self._outline_color) for dx, dy in offsets: painter.translate(dx, dy) super().paint(painter, option, widget) painter.translate(-dx, -dy) # 원래 색상 복원 self.setDefaultTextColor(original_color) painter.restore() # 원래 텍스트 렌더링 super().paint(painter, option, widget) @dataclass class EditAction: """편집 액션 (Undo/Redo용)""" action_type: str # "add", "delete", "move", "resize" items: List[QGraphicsItem] old_data: Optional[Any] = None new_data: Optional[Any] = None class EditableGraphicsView(QGraphicsView): """편집 가능한 이미지 뷰""" image_changed = Signal(int) editing_changed = Signal(bool) undo_redo_changed = Signal(bool, bool) # can_undo, can_redo def __init__(self, parent=None): super().__init__(parent) self.scene = QGraphicsScene(self) self.setScene(self.scene) self.pixmap_item: Optional[QGraphicsPixmapItem] = None self.current_scale = 1.0 self.min_scale = 0.05 self.max_scale = 50.0 # 최대 50배 확대 # 이미지 목록 self.images: List[Attachment] = [] self.current_index = 0 self.original_pixmap: Optional[QPixmap] = None # 편집 상태 self.current_tool = EditTool.SELECT self.is_editing = False self.drawing_item = None self.drawing_start = QPointF() # 편집 설정 self.pen_color = QColor("#ef4444") # 빨간색 self.fill_color = QColor(0, 0, 0, 0) # 투명 self.pen_width = 3 self.font = QFont("맑은 고딕", 14) self.font_bold = False self.font_underline = False self.text_outline_color: Optional[QColor] = None # 돋보이기 효과 색상 self.use_shadow = True # 복사용 상태 self._is_copying = False # Alt 키 상태 (정사각형/원 그리기) self._alt_pressed = False # 그룹 관리 self.item_groups: List[QGraphicsItemGroup] = [] # 편집 아이템들 self.edit_items: List[QGraphicsItem] = [] # Undo/Redo 스택 self.undo_stack: List[EditAction] = [] self.redo_stack: List[EditAction] = [] # 상태 관리 self._cursor = CURSOR_DEFAULT self._is_panning = False self._is_drawing = False self._is_moving = False self._is_resizing = False # 선택/이동/리사이즈 상태 self._last_mouse_pos = QPointF() self._pan_start_pos = QPointF() self._move_start_positions: Dict[QGraphicsItem, QPointF] = {} self._resize_item: Optional[QGraphicsItem] = None self._resize_vertex_index: int = -1 self._resize_start_rect: Optional[QRectF] = None # 호버 상태 self._hover_item: Optional[QGraphicsItem] = None self._hover_vertex: int = -1 # 프리핸드 경로 self._freehand_points = [] # 설정 self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.SmoothPixmapTransform) self.setDragMode(QGraphicsView.NoDrag) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setBackgroundBrush(QColor("#0f0f0f")) self.setMouseTracking(True) # 마우스 추적 활성화 self.setStyleSheet(""" QGraphicsView { border: none; background-color: #0f0f0f; } """) def _override_cursor(self, cursor): """커서 변경""" if cursor != self._cursor: self._cursor = cursor self.setCursor(cursor) def _restore_cursor(self): """기본 커서로 복원""" if self.current_tool == EditTool.SELECT: self._override_cursor(CURSOR_DEFAULT) else: self._override_cursor(CURSOR_DRAW) def set_tool(self, tool: EditTool): """편집 도구 설정""" self.current_tool = tool self.is_editing = (tool != EditTool.SELECT) self.editing_changed.emit(self.is_editing) if tool == EditTool.SELECT: self._override_cursor(CURSOR_DEFAULT) else: self._override_cursor(CURSOR_DRAW) # 선택 해제 self.scene.clearSelection() def set_images(self, images: List[Attachment], start_index: int = 0): """이미지 목록 설정""" self.images = images self.current_index = max(0, min(start_index, len(images) - 1)) self._load_current_image() def _load_current_image(self): """현재 이미지 로드""" if not self.images or self.current_index >= len(self.images): return attachment = self.images[self.current_index] full_path = attachment.get_full_path() if not full_path.exists(): logger.error("이미지 파일이 없습니다: %s", full_path) return pixmap = QPixmap(str(full_path)) if pixmap.isNull(): logger.error("이미지 로드 실패: %s", full_path) return # 기존 아이템 제거 self.scene.clear() self.edit_items.clear() self.undo_stack.clear() self.redo_stack.clear() self._emit_undo_redo_state() # 원본 저장 self.original_pixmap = pixmap # 새 이미지 추가 self.pixmap_item = self.scene.addPixmap(pixmap) self.pixmap_item.setZValue(-1) self.scene.setSceneRect(pixmap.rect().toRectF()) # 화면에 맞춤 self._fit_to_view() self.image_changed.emit(self.current_index) def _emit_undo_redo_state(self): """Undo/Redo 상태 시그널 발생""" can_undo = len(self.undo_stack) > 0 can_redo = len(self.redo_stack) > 0 self.undo_redo_changed.emit(can_undo, can_redo) def get_current_image_info(self) -> Dict[str, Any]: """현재 이미지 정보 반환""" if not self.images or self.current_index >= len(self.images): return {} attachment = self.images[self.current_index] full_path = attachment.get_full_path() info = { "filename": attachment.filename, "title": attachment.title, "path": str(full_path), } if self.original_pixmap and not self.original_pixmap.isNull(): info["width"] = self.original_pixmap.width() info["height"] = self.original_pixmap.height() if full_path.exists(): size_bytes = full_path.stat().st_size if size_bytes > 1024 * 1024: info["size"] = f"{size_bytes / (1024 * 1024):.1f} MB" else: info["size"] = f"{size_bytes / 1024:.1f} KB" return info def _fit_to_view(self): """이미지를 뷰에 맞춤""" if not self.pixmap_item: return self.resetTransform() self.current_scale = 1.0 view_rect = self.viewport().rect() scene_rect = self.sceneRect() if scene_rect.width() == 0 or scene_rect.height() == 0: return margin = 40 scale_x = (view_rect.width() - margin) / scene_rect.width() scale_y = (view_rect.height() - margin) / scene_rect.height() scale = min(scale_x, scale_y) self.scale(scale, scale) self.current_scale = scale self.centerOn(self.pixmap_item) def show_original_size(self): """원본 크기로 표시""" if not self.pixmap_item: return self.resetTransform() self.current_scale = 1.0 self.centerOn(self.pixmap_item) def zoom_in(self, factor: float = 1.25, anchor_point: QPointF = None): """확대""" new_scale = self.current_scale * factor if new_scale > self.max_scale: return if anchor_point: # 마우스 위치 기준으로 확대 old_pos = self.mapToScene(anchor_point.toPoint()) self.scale(factor, factor) new_pos = self.mapToScene(anchor_point.toPoint()) delta = old_pos - new_pos # 방향 수정 self.horizontalScrollBar().setValue( self.horizontalScrollBar().value() - int(delta.x() * self.current_scale) ) self.verticalScrollBar().setValue( self.verticalScrollBar().value() - int(delta.y() * self.current_scale) ) else: self.scale(factor, factor) self.current_scale = new_scale def zoom_out(self, factor: float = 1.25, anchor_point: QPointF = None): """축소""" new_scale = self.current_scale / factor if new_scale < self.min_scale: return if anchor_point: # 마우스 위치 기준으로 축소 old_pos = self.mapToScene(anchor_point.toPoint()) self.scale(1 / factor, 1 / factor) new_pos = self.mapToScene(anchor_point.toPoint()) delta = old_pos - new_pos # 방향 수정 self.horizontalScrollBar().setValue( self.horizontalScrollBar().value() - int(delta.x() * self.current_scale) ) self.verticalScrollBar().setValue( self.verticalScrollBar().value() - int(delta.y() * self.current_scale) ) else: self.scale(1 / factor, 1 / factor) self.current_scale = new_scale def next_image(self): """다음 이미지""" if self.current_index < len(self.images) - 1: self.current_index += 1 self._load_current_image() def prev_image(self): """이전 이미지""" if self.current_index > 0: self.current_index -= 1 self._load_current_image() def _create_pen(self) -> QPen: """현재 설정으로 펜 생성""" pen = QPen(self.pen_color, self.pen_width) pen.setJoinStyle(Qt.RoundJoin) pen.setCapStyle(Qt.RoundCap) return pen def _create_brush(self) -> QBrush: """현재 설정으로 브러시 생성""" return QBrush(self.fill_color) def _apply_shadow(self, item: QGraphicsItem): """그림자 효과 적용""" if self.use_shadow: shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(8) shadow.setColor(QColor(0, 0, 0, 100)) shadow.setOffset(3, 3) item.setGraphicsEffect(shadow) def _create_arrow_polygon(self, start: QPointF, end: QPointF) -> QPolygonF: """화살표 폴리곤 생성""" import math arrow_size = 15 dx = end.x() - start.x() dy = end.y() - start.y() length = math.sqrt(dx * dx + dy * dy) if length == 0: return QPolygonF() angle = math.atan2(dy, dx) p1 = end p2 = QPointF( end.x() - arrow_size * math.cos(angle - math.pi / 6), end.y() - arrow_size * math.sin(angle - math.pi / 6) ) p3 = QPointF( end.x() - arrow_size * math.cos(angle + math.pi / 6), end.y() - arrow_size * math.sin(angle + math.pi / 6) ) return QPolygonF([p1, p2, p3]) def _get_item_vertices(self, item: QGraphicsItem) -> List[QPointF]: """아이템의 버텍스(꼭지점) 목록 반환""" vertices = [] if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)): rect = item.rect() pos = item.pos() vertices = [ pos + rect.topLeft(), pos + rect.topRight(), pos + rect.bottomRight(), pos + rect.bottomLeft(), ] elif isinstance(item, QGraphicsLineItem): line = item.line() pos = item.pos() vertices = [ pos + line.p1(), pos + line.p2(), ] elif isinstance(item, QGraphicsTextItem): # 텍스트 아이템도 핸들 지원 rect = item.boundingRect() pos = item.pos() vertices = [ pos + rect.topLeft(), pos + rect.topRight(), pos + rect.bottomRight(), pos + rect.bottomLeft(), ] return vertices def _find_vertex_at(self, pos: QPointF) -> Tuple[Optional[QGraphicsItem], int]: """주어진 위치에서 가장 가까운 버텍스 찾기""" for item in reversed(self.edit_items): vertices = self._get_item_vertices(item) for i, vertex in enumerate(vertices): if self._distance(pos, vertex) < VERTEX_EPSILON / self.current_scale: return item, i return None, -1 def _find_item_at(self, pos: QPointF) -> Optional[QGraphicsItem]: """주어진 위치의 편집 아이템 찾기""" items = self.scene.items(pos) for item in items: if item in self.edit_items: return item return None def _distance(self, p1: QPointF, p2: QPointF) -> float: """두 점 사이의 거리""" import math dx = p1.x() - p2.x() dy = p1.y() - p2.y() return math.sqrt(dx * dx + dy * dy) def _add_edit_item(self, item: QGraphicsItem, already_in_scene: bool = False): """편집 아이템 추가 (Undo 지원) Args: item: 추가할 그래픽 아이템 already_in_scene: 이미 scene에 추가되어 있는지 여부 """ if not already_in_scene: self.scene.addItem(item) self.edit_items.append(item) action = EditAction(action_type="add", items=[item]) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() def add_text(self, pos: QPointF, text: str = "텍스트"): """텍스트 추가""" text_item = OutlineTextItem(text) text_item.setPos(pos) # 폰트 설정 적용 font = QFont(self.font) font.setBold(self.font_bold) font.setUnderline(self.font_underline) text_item.setFont(font) text_item.setDefaultTextColor(self.pen_color) text_item.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) # 돋보이기 효과 적용 if self.text_outline_color: text_item.set_outline_color(self.text_outline_color) self._apply_shadow(text_item) self._add_edit_item(text_item) return text_item def _copy_item(self, item: QGraphicsItem) -> Optional[QGraphicsItem]: """아이템 복사""" copied = None if isinstance(item, QGraphicsRectItem): copied = QGraphicsRectItem(item.rect()) copied.setPen(item.pen()) copied.setBrush(item.brush()) elif isinstance(item, QGraphicsEllipseItem): copied = QGraphicsEllipseItem(item.rect()) copied.setPen(item.pen()) copied.setBrush(item.brush()) elif isinstance(item, QGraphicsLineItem): copied = QGraphicsLineItem(item.line()) copied.setPen(item.pen()) elif isinstance(item, OutlineTextItem): copied = OutlineTextItem(item.toPlainText()) copied.setFont(item.font()) copied.setDefaultTextColor(item.defaultTextColor()) copied.set_outline_color(item.get_outline_color()) elif isinstance(item, QGraphicsTextItem): copied = OutlineTextItem(item.toPlainText()) copied.setFont(item.font()) copied.setDefaultTextColor(item.defaultTextColor()) elif isinstance(item, QGraphicsPolygonItem): copied = QGraphicsPolygonItem(item.polygon()) copied.setPen(item.pen()) copied.setBrush(item.brush()) if copied: copied.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) # 그림자 효과 복사 if item.graphicsEffect(): self._apply_shadow(copied) return copied def group_selected(self): """선택된 아이템들 그룹화""" selected = [item for item in self.scene.selectedItems() if item in self.edit_items] if len(selected) < 2: return group = self.scene.createItemGroup(selected) group.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) # 그룹에서 개별 아이템 제거하고 그룹 추가 for item in selected: if item in self.edit_items: self.edit_items.remove(item) self.edit_items.append(group) self.item_groups.append(group) action = EditAction(action_type="add", items=[group], old_data=selected) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() def ungroup_selected(self): """선택된 그룹 해제""" selected = self.scene.selectedItems() for item in selected: if isinstance(item, QGraphicsItemGroup) and item in self.item_groups: children = item.childItems() self.scene.destroyItemGroup(item) if item in self.edit_items: self.edit_items.remove(item) if item in self.item_groups: self.item_groups.remove(item) for child in children: child.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) self.edit_items.append(child) def bring_to_front(self): """선택된 아이템을 맨 앞으로""" selected = self.scene.selectedItems() for item in selected: if item in self.edit_items: max_z = max((i.zValue() for i in self.edit_items), default=0) item.setZValue(max_z + 1) def send_to_back(self): """선택된 아이템을 맨 뒤로""" selected = self.scene.selectedItems() for item in selected: if item in self.edit_items: min_z = min((i.zValue() for i in self.edit_items), default=0) item.setZValue(min_z - 1) def bring_forward(self): """선택된 아이템을 한 단계 앞으로""" selected = self.scene.selectedItems() for item in selected: if item in self.edit_items: item.setZValue(item.zValue() + 1) def send_backward(self): """선택된 아이템을 한 단계 뒤로""" selected = self.scene.selectedItems() for item in selected: if item in self.edit_items: item.setZValue(item.zValue() - 1) def undo(self): """실행 취소""" if not self.undo_stack: return action = self.undo_stack.pop() if action.action_type == "add": for item in action.items: if item in self.edit_items: self.edit_items.remove(item) self.scene.removeItem(item) elif action.action_type == "delete": for item in action.items: self.scene.addItem(item) self.edit_items.append(item) elif action.action_type == "move": if action.old_data: for item, old_pos in zip(action.items, action.old_data): item.setPos(old_pos) elif action.action_type == "resize": if action.old_data: item = action.items[0] old_rect = action.old_data if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)): item.setRect(old_rect) elif isinstance(item, QGraphicsLineItem): item.setLine(QLineF(old_rect.topLeft(), old_rect.bottomRight())) self.redo_stack.append(action) self._emit_undo_redo_state() def redo(self): """다시 실행""" if not self.redo_stack: return action = self.redo_stack.pop() if action.action_type == "add": for item in action.items: self.scene.addItem(item) self.edit_items.append(item) elif action.action_type == "delete": for item in action.items: if item in self.edit_items: self.edit_items.remove(item) self.scene.removeItem(item) elif action.action_type == "move": if action.new_data: for item, new_pos in zip(action.items, action.new_data): item.setPos(new_pos) elif action.action_type == "resize": if action.new_data: item = action.items[0] new_rect = action.new_data if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)): item.setRect(new_rect) elif isinstance(item, QGraphicsLineItem): item.setLine(QLineF(new_rect.topLeft(), new_rect.bottomRight())) self.undo_stack.append(action) self._emit_undo_redo_state() def delete_selected(self): """선택된 아이템 삭제""" selected = self.scene.selectedItems() if not selected: return items_to_delete = [item for item in selected if item in self.edit_items] if not items_to_delete: return action = EditAction(action_type="delete", items=items_to_delete) self.undo_stack.append(action) self.redo_stack.clear() for item in items_to_delete: self.edit_items.remove(item) self.scene.removeItem(item) self._emit_undo_redo_state() def clear_edits(self): """모든 편집 내용 삭제""" if not self.edit_items: return action = EditAction(action_type="delete", items=list(self.edit_items)) self.undo_stack.append(action) self.redo_stack.clear() for item in self.edit_items: self.scene.removeItem(item) self.edit_items.clear() self._emit_undo_redo_state() def update_selected_color(self, color: QColor): """선택된 아이템의 색상 변경""" selected = self.scene.selectedItems() if not selected: return for item in selected: if item not in self.edit_items: continue if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)): pen = item.pen() pen.setColor(color) item.setPen(pen) elif isinstance(item, QGraphicsLineItem): pen = item.pen() pen.setColor(color) item.setPen(pen) elif isinstance(item, QGraphicsPolygonItem): pen = item.pen() pen.setColor(color) item.setPen(pen) brush = item.brush() brush.setColor(color) item.setBrush(brush) elif isinstance(item, QGraphicsTextItem): item.setDefaultTextColor(color) self.viewport().update() def update_selected_width(self, width: int): """선택된 아이템의 두께 변경""" selected = self.scene.selectedItems() if not selected: return for item in selected: if item not in self.edit_items: continue if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsPolygonItem)): pen = item.pen() pen.setWidth(width) item.setPen(pen) elif isinstance(item, QGraphicsTextItem): font = item.font() # 텍스트는 두께 대신 폰트 크기 조정 (두께 * 5) font.setPointSize(max(10, width * 5)) item.setFont(font) self.viewport().update() def save_edited_image(self, save_path: str = None) -> bool: """편집된 이미지 저장""" if not self.pixmap_item: return False try: # 저장 전 선택 해제 (선택 핸들이 저장되지 않도록) self.scene.clearSelection() rect = self.scene.sceneRect() pixmap = QPixmap(int(rect.width()), int(rect.height())) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.SmoothPixmapTransform) self.scene.render(painter) painter.end() if not save_path: attachment = self.images[self.current_index] full_path = attachment.get_full_path() stem = full_path.stem suffix = full_path.suffix save_path = str(full_path.parent / f"{stem}_edited{suffix}") success = pixmap.save(save_path) if success: logger.info("편집된 이미지 저장: %s", save_path) return success except Exception as e: logger.error("이미지 저장 실패: %s", e) return False def wheelEvent(self, event: QWheelEvent): """마우스 휠 이벤트""" modifiers = event.modifiers() if modifiers & Qt.ControlModifier: # Ctrl + 휠: 확대/축소 try: anchor_point = QPointF(event.position()) except AttributeError: anchor_point = QPointF(event.pos()) if event.angleDelta().y() > 0: self.zoom_in(1.15, anchor_point) else: self.zoom_out(1.15, anchor_point) event.accept() else: # 휠만: 이전/다음 이미지 if event.angleDelta().y() > 0: self.prev_image() else: self.next_image() event.accept() def mouseMoveEvent(self, event: QMouseEvent): """마우스 이동 이벤트""" scene_pos = self.mapToScene(event.pos()) # 패닝 중 if self._is_panning: delta = event.pos() - self._last_mouse_pos.toPoint() self._last_mouse_pos = QPointF(event.pos()) self.horizontalScrollBar().setValue( self.horizontalScrollBar().value() - delta.x() ) self.verticalScrollBar().setValue( self.verticalScrollBar().value() - delta.y() ) event.accept() return # 리사이즈 중 if self._is_resizing and self._resize_item: self._handle_resize(scene_pos) event.accept() return # 이동 중 if self._is_moving: delta = scene_pos - self._last_mouse_pos self._last_mouse_pos = scene_pos for item in self.scene.selectedItems(): if item in self.edit_items: item.moveBy(delta.x(), delta.y()) event.accept() return # 그리기 중 if self._is_drawing: self._update_drawing(scene_pos) super().mouseMoveEvent(event) return # 호버 처리 (SELECT 모드) if self.current_tool == EditTool.SELECT: # 버텍스 호버 체크 vertex_item, vertex_idx = self._find_vertex_at(scene_pos) if vertex_item and vertex_idx >= 0: self._hover_item = vertex_item self._hover_vertex = vertex_idx self._override_cursor(CURSOR_SIZE_ALL) self.viewport().update() return # 아이템 호버 체크 hover_item = self._find_item_at(scene_pos) if hover_item: self._hover_item = hover_item self._hover_vertex = -1 self._override_cursor(CURSOR_GRAB) self.viewport().update() return # 아무것도 없으면 self._hover_item = None self._hover_vertex = -1 self._restore_cursor() super().mouseMoveEvent(event) def _handle_resize(self, pos: QPointF): """리사이즈 처리""" if not self._resize_item or not self._resize_start_rect: return item = self._resize_item idx = self._resize_vertex_index start_rect = self._resize_start_rect if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)): # 사각형/타원 리사이즈 item_pos = item.pos() local_pos = pos - item_pos if idx == 0: # top-left new_rect = QRectF(local_pos, start_rect.bottomRight()) elif idx == 1: # top-right new_rect = QRectF( QPointF(start_rect.left(), local_pos.y()), QPointF(local_pos.x(), start_rect.bottom()) ) elif idx == 2: # bottom-right new_rect = QRectF(start_rect.topLeft(), local_pos) elif idx == 3: # bottom-left new_rect = QRectF( QPointF(local_pos.x(), start_rect.top()), QPointF(start_rect.right(), local_pos.y()) ) else: return new_rect = new_rect.normalized() # Alt 누르면 정사각형/원 if self._alt_pressed: size = max(new_rect.width(), new_rect.height()) new_rect.setWidth(size) new_rect.setHeight(size) item.setRect(new_rect) elif isinstance(item, QGraphicsLineItem): # 선 리사이즈 item_pos = item.pos() local_pos = pos - item_pos line = item.line() if idx == 0: item.setLine(QLineF(local_pos, line.p2())) elif idx == 1: item.setLine(QLineF(line.p1(), local_pos)) elif isinstance(item, QGraphicsTextItem): # 텍스트 아이템 리사이즈 - 폰트 크기 조절 item_pos = item.pos() local_pos = pos - item_pos # 우하단 꼭지점 기준으로 폰트 크기 계산 if idx == 2: # bottom-right new_height = max(10, local_pos.y()) font = item.font() # 높이에 비례해서 폰트 크기 조절 new_font_size = max(8, int(new_height / 1.5)) font.setPointSize(new_font_size) item.setFont(font) self.viewport().update() def mousePressEvent(self, event: QMouseEvent): """마우스 누름 이벤트""" scene_pos = self.mapToScene(event.pos()) if event.button() == Qt.LeftButton: if self.current_tool == EditTool.SELECT: # 버텍스 리사이즈 시작 체크 vertex_item, vertex_idx = self._find_vertex_at(scene_pos) if vertex_item and vertex_idx >= 0: self._is_resizing = True self._resize_item = vertex_item self._resize_vertex_index = vertex_idx if isinstance(vertex_item, (QGraphicsRectItem, QGraphicsEllipseItem)): self._resize_start_rect = vertex_item.rect() elif isinstance(vertex_item, QGraphicsLineItem): line = vertex_item.line() self._resize_start_rect = QRectF(line.p1(), line.p2()) elif isinstance(vertex_item, QGraphicsTextItem): self._resize_start_rect = vertex_item.boundingRect() self._override_cursor(CURSOR_SIZE_ALL) event.accept() return # 아이템 이동 시작 체크 click_item = self._find_item_at(scene_pos) if click_item: # 아이템 선택 modifiers = QApplication.keyboardModifiers() if not (modifiers & Qt.ControlModifier): self.scene.clearSelection() click_item.setSelected(True) # Ctrl+드래그 = 복사 self._is_copying = bool(modifiers & Qt.ControlModifier) # 이동 시작 self._is_moving = True self._last_mouse_pos = scene_pos self._move_start_positions.clear() for item in self.scene.selectedItems(): if item in self.edit_items: self._move_start_positions[item] = item.pos() self._override_cursor(CURSOR_MOVE) event.accept() return # 빈 공간 클릭: 선택 해제 및 패닝 시작 self.scene.clearSelection() self._is_panning = True self._last_mouse_pos = QPointF(event.pos()) self._override_cursor(CURSOR_GRAB) event.accept() return else: # 그리기 모드 self._is_drawing = True self.drawing_start = scene_pos self._start_drawing(scene_pos) elif event.button() == Qt.MiddleButton: # 중간 버튼: 패닝 self._is_panning = True self._last_mouse_pos = QPointF(event.pos()) self._override_cursor(CURSOR_GRAB) event.accept() return super().mousePressEvent(event) def _start_drawing(self, pos: QPointF): """그리기 시작""" pen = self._create_pen() brush = self._create_brush() if self.current_tool == EditTool.RECT: self.drawing_item = QGraphicsRectItem(QRectF(pos, pos)) self.drawing_item.setPen(pen) self.drawing_item.setBrush(brush) elif self.current_tool == EditTool.ELLIPSE: self.drawing_item = QGraphicsEllipseItem(QRectF(pos, pos)) self.drawing_item.setPen(pen) self.drawing_item.setBrush(brush) elif self.current_tool == EditTool.LINE: self.drawing_item = QGraphicsLineItem(QLineF(pos, pos)) self.drawing_item.setPen(pen) elif self.current_tool == EditTool.ARROW: self.drawing_item = QGraphicsLineItem(QLineF(pos, pos)) self.drawing_item.setPen(pen) elif self.current_tool == EditTool.TEXT: text, ok = QInputDialog.getText(self, "텍스트 입력", "텍스트:") if ok and text: self.add_text(pos, text) self._is_drawing = False return elif self.current_tool == EditTool.FREEHAND: self._freehand_points = [pos] return if self.drawing_item: self.scene.addItem(self.drawing_item) def _update_drawing(self, pos: QPointF): """그리기 업데이트""" if not self.drawing_item: if self.current_tool == EditTool.FREEHAND: self._freehand_points.append(pos) return start = self.drawing_start if self.current_tool == EditTool.RECT: rect = QRectF(start, pos).normalized() # Alt 누르면 정사각형 if self._alt_pressed: size = max(rect.width(), rect.height()) rect.setWidth(size) rect.setHeight(size) self.drawing_item.setRect(rect) elif self.current_tool == EditTool.ELLIPSE: rect = QRectF(start, pos).normalized() # Alt 누르면 원 if self._alt_pressed: size = max(rect.width(), rect.height()) rect.setWidth(size) rect.setHeight(size) self.drawing_item.setRect(rect) elif self.current_tool in (EditTool.LINE, EditTool.ARROW): self.drawing_item.setLine(QLineF(start, pos)) def mouseReleaseEvent(self, event: QMouseEvent): """마우스 놓음 이벤트""" if event.button() == Qt.LeftButton: if self._is_panning: self._is_panning = False self._restore_cursor() elif self._is_resizing: # 리사이즈 완료 - Undo 스택에 추가 if self._resize_item and self._resize_start_rect: if isinstance(self._resize_item, (QGraphicsRectItem, QGraphicsEllipseItem)): new_rect = self._resize_item.rect() elif isinstance(self._resize_item, QGraphicsLineItem): line = self._resize_item.line() new_rect = QRectF(line.p1(), line.p2()) else: new_rect = None if new_rect and new_rect != self._resize_start_rect: action = EditAction( action_type="resize", items=[self._resize_item], old_data=self._resize_start_rect, new_data=new_rect ) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() self._is_resizing = False self._resize_item = None self._resize_start_rect = None self._restore_cursor() elif self._is_moving: # 이동 완료 - Undo 스택에 추가 if self._move_start_positions: items = list(self._move_start_positions.keys()) old_positions = list(self._move_start_positions.values()) new_positions = [item.pos() for item in items] moved = any( old != new for old, new in zip(old_positions, new_positions) ) if moved: if self._is_copying: # 복사 모드: 원본을 원래 위치로, 새 복사본 생성 for item, old_pos in zip(items, old_positions): item.setPos(old_pos) # 복사본 생성 copied_items = [] for item, new_pos in zip(items, new_positions): copied = self._copy_item(item) if copied: copied.setPos(new_pos) self.scene.addItem(copied) self.edit_items.append(copied) copied_items.append(copied) if copied_items: action = EditAction(action_type="add", items=copied_items) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() else: # 일반 이동 action = EditAction( action_type="move", items=items, old_data=old_positions, new_data=new_positions ) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() self._is_moving = False self._is_copying = False self._move_start_positions.clear() self._restore_cursor() elif self._is_drawing: self._finish_drawing() self._is_drawing = False elif event.button() == Qt.MiddleButton: if self._is_panning: self._is_panning = False self._restore_cursor() super().mouseReleaseEvent(event) def _finish_drawing(self): """그리기 완료""" if self.drawing_item: # 선택/이동 가능하게 설정 self.drawing_item.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) # 화살표인 경우 머리 추가 if self.current_tool == EditTool.ARROW: line = self.drawing_item.line() arrow_head = self._create_arrow_polygon(line.p1(), line.p2()) if not arrow_head.isEmpty(): arrow_item = QGraphicsPolygonItem(arrow_head) arrow_item.setPen(self._create_pen()) arrow_item.setBrush(QBrush(self.pen_color)) arrow_item.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) self._apply_shadow(arrow_item) self.scene.addItem(arrow_item) self.edit_items.append(arrow_item) action = EditAction(action_type="add", items=[self.drawing_item, arrow_item]) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() self._apply_shadow(self.drawing_item) self.edit_items.append(self.drawing_item) self.drawing_item = None return self._apply_shadow(self.drawing_item) # 이미 _start_drawing에서 scene에 추가됨 self._add_edit_item(self.drawing_item, already_in_scene=True) self.drawing_item = None elif self.current_tool == EditTool.FREEHAND and len(self._freehand_points) > 1: pen = self._create_pen() freehand_items = [] for i in range(len(self._freehand_points) - 1): line = QGraphicsLineItem(QLineF( self._freehand_points[i], self._freehand_points[i + 1] )) line.setPen(pen) line.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable ) self.scene.addItem(line) self.edit_items.append(line) freehand_items.append(line) if freehand_items: action = EditAction(action_type="add", items=freehand_items) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() self._freehand_points.clear() def mouseDoubleClickEvent(self, event: QMouseEvent): """더블클릭 이벤트""" if event.button() == Qt.LeftButton and self.current_tool == EditTool.SELECT: scene_pos = self.mapToScene(event.pos()) click_item = self._find_item_at(scene_pos) if not click_item: # 빈 공간 더블클릭: 줌 토글 if abs(self.current_scale - 1.0) < 0.1: self._fit_to_view() else: self.show_original_size() return super().mouseDoubleClickEvent(event) def contextMenuEvent(self, event): """컨텍스트 메뉴""" scene_pos = self.mapToScene(event.pos()) click_item = self._find_item_at(scene_pos) menu = QMenu(self) menu.setStyleSheet(""" QMenu { background-color: #1e293b; color: #f8fafc; border: 1px solid #334155; padding: 4px; } QMenu::item { padding: 6px 24px; } QMenu::item:selected { background-color: #3b82f6; } QMenu::separator { height: 1px; background-color: #334155; margin: 4px 8px; } """) selected = self.scene.selectedItems() if click_item or selected: # 객체가 있는 경우 if click_item and click_item not in selected: self.scene.clearSelection() click_item.setSelected(True) selected = [click_item] # 복사 copy_action = menu.addAction("📋 복사") copy_action.triggered.connect(lambda: self._copy_selected_to_clipboard()) # 삭제 delete_action = menu.addAction("🗑 삭제") delete_action.triggered.connect(self.delete_selected) menu.addSeparator() # 배치 arrange_menu = menu.addMenu("📐 배치") front_action = arrange_menu.addAction("맨 앞으로") front_action.triggered.connect(self.bring_to_front) forward_action = arrange_menu.addAction("앞으로") forward_action.triggered.connect(self.bring_forward) backward_action = arrange_menu.addAction("뒤로") backward_action.triggered.connect(self.send_backward) back_action = arrange_menu.addAction("맨 뒤로") back_action.triggered.connect(self.send_to_back) menu.addSeparator() # 그룹 if len(selected) >= 2: group_action = menu.addAction("🔗 그룹 묶기") group_action.triggered.connect(self.group_selected) has_group = any(isinstance(item, QGraphicsItemGroup) for item in selected) if has_group: ungroup_action = menu.addAction("🔓 그룹 풀기") ungroup_action.triggered.connect(self.ungroup_selected) else: # 빈 공간 paste_action = menu.addAction("📋 붙여넣기") paste_action.triggered.connect(lambda: self._paste_from_clipboard(scene_pos)) menu.addSeparator() select_all_action = menu.addAction("전체 선택") select_all_action.triggered.connect(self.select_all) menu.exec(event.globalPos()) def _copy_selected_to_clipboard(self): """선택된 아이템을 클립보드에 복사 (내부용)""" selected = [item for item in self.scene.selectedItems() if item in self.edit_items] if selected: self._clipboard_items = selected def _paste_from_clipboard(self, pos: QPointF): """클립보드에서 붙여넣기""" if not hasattr(self, '_clipboard_items') or not self._clipboard_items: return pasted_items = [] for item in self._clipboard_items: copied = self._copy_item(item) if copied: # 약간 오프셋을 주어 붙여넣기 copied.setPos(pos) self.scene.addItem(copied) self.edit_items.append(copied) pasted_items.append(copied) if pasted_items: action = EditAction(action_type="add", items=pasted_items) self.undo_stack.append(action) self.redo_stack.clear() self._emit_undo_redo_state() def select_all(self): """모든 편집 아이템 선택""" for item in self.edit_items: item.setSelected(True) def keyPressEvent(self, event: QKeyEvent): """키 누름 이벤트""" if event.key() == Qt.Key_Alt: self._alt_pressed = True super().keyPressEvent(event) def keyReleaseEvent(self, event: QKeyEvent): """키 놓음 이벤트""" if event.key() == Qt.Key_Alt: self._alt_pressed = False super().keyReleaseEvent(event) def paintEvent(self, event): """페인트 이벤트 - 버텍스 핸들 그리기""" super().paintEvent(event) # 선택된 아이템이나 호버 아이템의 버텍스 핸들 그리기 if self.current_tool != EditTool.SELECT: return painter = QPainter(self.viewport()) painter.setRenderHint(QPainter.Antialiasing) items_to_draw = set() # 선택된 아이템 for item in self.scene.selectedItems(): if item in self.edit_items: items_to_draw.add(item) # 호버 아이템 if self._hover_item and self._hover_item in self.edit_items: items_to_draw.add(self._hover_item) for item in items_to_draw: vertices = self._get_item_vertices(item) for i, vertex in enumerate(vertices): # Scene 좌표를 View 좌표로 변환 view_pos = self.mapFromScene(vertex) # 호버된 버텍스는 크게 if item == self._hover_item and i == self._hover_vertex: size = 10 painter.setBrush(QBrush(QColor("#3b82f6"))) else: size = 7 painter.setBrush(QBrush(QColor("#ffffff"))) painter.setPen(QPen(QColor("#1e293b"), 2)) painter.drawEllipse(view_pos, size, size) painter.end() # resizeEvent를 오버라이드하지 않음 - 확대 상태 유지를 위해 # 초기 로드 시에만 _fit_to_view가 호출됨 class ToolButton(QToolButton): """도구 버튼""" def __init__(self, text: str, tooltip: str = "", checkable: bool = True, size: int = 42, parent=None): super().__init__(parent) self.setText(text) self.setToolTip(tooltip) self.setCheckable(checkable) self.setFixedSize(size, size) self.setCursor(Qt.PointingHandCursor) self.setStyleSheet(f""" QToolButton {{ background-color: #334155; color: #ffffff; border: none; border-radius: 6px; font-size: {int(size * 0.45)}px; }} QToolButton:hover {{ background-color: #475569; }} QToolButton:checked {{ background-color: #3b82f6; }} QToolButton:disabled {{ background-color: #1e293b; color: #475569; }} """) class ColorButton(QPushButton): """색상 선택 버튼""" color_changed = Signal(QColor) def __init__(self, color: QColor, parent=None): super().__init__(parent) self._color = color self.setFixedSize(28, 28) self.setCursor(Qt.PointingHandCursor) self.clicked.connect(self._on_clicked) self._update_style() def _update_style(self): self.setStyleSheet(f""" QPushButton {{ background-color: {self._color.name()}; border: 2px solid #ffffff; border-radius: 14px; }} QPushButton:hover {{ border: 2px solid #3b82f6; }} """) def _on_clicked(self): color = QColorDialog.getColor(self._color, self, "색상 선택") if color.isValid(): self._color = color self._update_style() self.color_changed.emit(color) def color(self) -> QColor: return self._color def set_color(self, color: QColor): self._color = color self._update_style() class ImageViewerDialog(QDialog): """이미지 뷰어/편집기 다이얼로그""" # 저장 방식 상수 SAVE_COPY = "copy" # 사본 저장 SAVE_ORIGINAL = "original" # 원본 저장 def __init__( self, parent=None, images: List[Attachment] = None, start_index: int = 0, record_info: dict = None # 레코드 정보 (파일명 생성용) ): super().__init__(parent) self.images = images or [] self.start_index = start_index self.record_info = record_info or {} self.save_mode = self.SAVE_ORIGINAL # 기본값: 원본 저장 self._is_saved = True # 편집 후 저장 여부 self._setup_window() self._setup_ui() if self.images: self.image_view.set_images(self.images, start_index) self._update_info() # 편집 변경 시 저장 상태 업데이트 self.image_view.undo_redo_changed.connect(self._on_edit_changed) def _setup_window(self): """창 설정""" self.setWindowTitle("이미지 뷰어") self.setModal(True) self.resize(1200, 900) self.setMinimumSize(800, 600) self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground, False) self.setStyleSheet(""" QDialog { background-color: #0f0f0f; } """) def _setup_ui(self): """UI 설정""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.image_view = EditableGraphicsView() self.image_view.image_changed.connect(self._update_info) self.image_view.undo_redo_changed.connect(self._on_undo_redo_changed) self._create_top_bar(layout) self._create_toolbar(layout) # 메인 영역: 이미지뷰 + 오른쪽 명령어 패널 main_widget = QWidget() main_layout = QHBoxLayout(main_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_layout.addWidget(self.image_view, 1) self._create_command_panel(main_layout) layout.addWidget(main_widget, 1) self._create_bottom_bar(layout) def _create_top_bar(self, layout: QVBoxLayout): """상단 바 생성""" bar = QWidget() bar.setFixedHeight(50) bar.setStyleSheet("background-color: #1a1a1a;") bar_layout = QHBoxLayout(bar) bar_layout.setContentsMargins(16, 8, 16, 8) self.info_label = QLabel() self.info_label.setStyleSheet("color: #94a3b8; font-size: 12px;") bar_layout.addWidget(self.info_label, 1) close_btn = QPushButton("✕") close_btn.setFixedSize(32, 32) close_btn.setCursor(Qt.PointingHandCursor) close_btn.clicked.connect(self.close) close_btn.setStyleSheet(""" QPushButton { background-color: transparent; color: #ffffff; border: none; border-radius: 16px; font-size: 18px; } QPushButton:hover { background-color: #ef4444; } """) bar_layout.addWidget(close_btn) layout.addWidget(bar) def _create_toolbar(self, layout: QVBoxLayout): """도구 바 생성 (툴박스만 - 높이 1.5배)""" toolbar = QWidget() toolbar.setFixedHeight(75) # 50 * 1.5 = 75 toolbar.setStyleSheet("background-color: #1e293b; border-bottom: 1px solid #334155;") toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(16, 12, 16, 12) toolbar_layout.setSpacing(10) # Undo/Redo self.undo_btn = ToolButton("↶", "실행 취소 (Ctrl+Z)", checkable=False, size=48) self.undo_btn.setEnabled(False) self.undo_btn.clicked.connect(self.image_view.undo) toolbar_layout.addWidget(self.undo_btn) self.redo_btn = ToolButton("↷", "다시 실행 (Ctrl+Y)", checkable=False, size=48) self.redo_btn.setEnabled(False) self.redo_btn.clicked.connect(self.image_view.redo) toolbar_layout.addWidget(self.redo_btn) toolbar_layout.addWidget(self._create_separator()) # 도구 버튼 self.tool_group = QButtonGroup(self) self.tool_group.setExclusive(True) tools = [ ("🖱", "선택/이동 [V]\n방향키: 미세이동\nShift+방향키: 10px 이동", EditTool.SELECT), ("▢", "사각형 [R]", EditTool.RECT), ("○", "원/타원 [E]", EditTool.ELLIPSE), ("╱", "직선 [L]", EditTool.LINE), ("→", "화살표 [A]", EditTool.ARROW), ("T", "텍스트 [T]", EditTool.TEXT), ("✏", "자유선 [P]", EditTool.FREEHAND), ] self.tool_buttons = {} for text, tooltip, tool in tools: btn = ToolButton(text, tooltip, size=48) btn.clicked.connect(lambda checked, t=tool: self.image_view.set_tool(t)) self.tool_group.addButton(btn) toolbar_layout.addWidget(btn) self.tool_buttons[tool] = btn if tool == EditTool.SELECT: btn.setChecked(True) toolbar_layout.addWidget(self._create_separator()) # 색상 color_label = QLabel("색상:") color_label.setStyleSheet("color: #94a3b8; font-size: 12px;") toolbar_layout.addWidget(color_label) self.color_btn = ColorButton(QColor("#ef4444")) self.color_btn.color_changed.connect(self._on_color_changed) toolbar_layout.addWidget(self.color_btn) # 색상명과 단축키 매핑 self.quick_colors = [ ("#ef4444", "빨강", "1"), ("#f97316", "주황", "2"), ("#eab308", "노랑", "3"), ("#22c55e", "초록", "4"), ("#3b82f6", "파랑", "5"), ("#8b5cf6", "보라", "6"), ("#ffffff", "흰색", "7"), ("#000000", "검정", "8"), ] for color_hex, color_name, shortcut in self.quick_colors: btn = QPushButton() btn.setFixedSize(24, 24) btn.setCursor(Qt.PointingHandCursor) btn.setToolTip(f"{color_name} [{shortcut}]") btn.setStyleSheet(f""" QPushButton {{ background-color: {color_hex}; border: 1px solid #475569; border-radius: 4px; }} QPushButton:hover {{ border: 2px solid #ffffff; }} """) btn.clicked.connect(lambda checked, c=color_hex: self._set_quick_color(c)) toolbar_layout.addWidget(btn) toolbar_layout.addWidget(self._create_separator()) # 두께 width_label = QLabel("두께:") width_label.setStyleSheet("color: #94a3b8; font-size: 12px;") toolbar_layout.addWidget(width_label) self.width_spin = QSpinBox() self.width_spin.setRange(1, 20) self.width_spin.setValue(3) self.width_spin.setFixedSize(60, 32) self.width_spin.setToolTip("선 두께 [ ] 또는 +/-") self.width_spin.valueChanged.connect(self._on_width_changed) self.width_spin.setStyleSheet(""" QSpinBox { background-color: #334155; color: #ffffff; border: 1px solid #475569; border-radius: 4px; padding: 2px 4px; font-size: 12px; } QSpinBox::up-button, QSpinBox::down-button { width: 16px; background-color: #475569; border: none; } QSpinBox::up-button:hover, QSpinBox::down-button:hover { background-color: #64748b; } """) toolbar_layout.addWidget(self.width_spin) toolbar_layout.addWidget(self._create_separator()) # 그림자 self.shadow_btn = ToolButton("◐", "그림자 효과 [S]", size=48) self.shadow_btn.setChecked(True) self.shadow_btn.clicked.connect(self._on_shadow_toggled) toolbar_layout.addWidget(self.shadow_btn) toolbar_layout.addStretch() layout.addWidget(toolbar) # 텍스트 전용 툴바 (두번째 줄) self._create_text_toolbar(layout) def _create_text_toolbar(self, layout: QVBoxLayout): """텍스트 전용 툴바 생성""" toolbar = QWidget() toolbar.setFixedHeight(50) toolbar.setStyleSheet("background-color: #1e293b; border-bottom: 1px solid #334155;") toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(16, 8, 16, 8) toolbar_layout.setSpacing(8) # 폰트 라벨 font_label = QLabel("📝 글꼴:") font_label.setStyleSheet("color: #94a3b8; font-size: 12px;") toolbar_layout.addWidget(font_label) # 폰트 선택 콤보박스 self.font_combo = QComboBox() self.font_combo.setFixedWidth(150) self.font_combo.setStyleSheet(""" QComboBox { background-color: #334155; color: #ffffff; border: 1px solid #475569; border-radius: 4px; padding: 4px 8px; font-size: 11px; } QComboBox::drop-down { border: none; width: 20px; } QComboBox QAbstractItemView { background-color: #1e293b; color: #ffffff; selection-background-color: #3b82f6; border: 1px solid #475569; } """) # 사용 가능한 폰트만 추가 for font_name in AVAILABLE_FONTS: self.font_combo.addItem(font_name) self.font_combo.setCurrentText("맑은 고딕") self.font_combo.currentTextChanged.connect(self._on_font_changed) toolbar_layout.addWidget(self.font_combo) toolbar_layout.addWidget(self._create_separator()) # 폰트 크기 size_label = QLabel("크기:") size_label.setStyleSheet("color: #94a3b8; font-size: 12px;") toolbar_layout.addWidget(size_label) self.font_size_spin = QSpinBox() self.font_size_spin.setRange(8, 200) self.font_size_spin.setValue(14) self.font_size_spin.setFixedSize(60, 28) self.font_size_spin.valueChanged.connect(self._on_font_size_changed) self.font_size_spin.setStyleSheet(""" QSpinBox { background-color: #334155; color: #ffffff; border: 1px solid #475569; border-radius: 4px; padding: 2px 4px; font-size: 11px; } QSpinBox::up-button, QSpinBox::down-button { width: 14px; background-color: #475569; border: none; } """) toolbar_layout.addWidget(self.font_size_spin) toolbar_layout.addWidget(self._create_separator()) # 굵게 self.bold_btn = ToolButton("B", "굵게", size=32) self.bold_btn.setStyleSheet(self.bold_btn.styleSheet() + """ QToolButton { font-weight: bold; } """) self.bold_btn.clicked.connect(self._on_bold_toggled) toolbar_layout.addWidget(self.bold_btn) # 밑줄 self.underline_btn = ToolButton("U", "밑줄", size=32) self.underline_btn.setStyleSheet(self.underline_btn.styleSheet() + """ QToolButton { text-decoration: underline; } """) self.underline_btn.clicked.connect(self._on_underline_toggled) toolbar_layout.addWidget(self.underline_btn) toolbar_layout.addWidget(self._create_separator()) # 돋보이기 (외곽선 효과) outline_label = QLabel("돋보이기:") outline_label.setStyleSheet("color: #94a3b8; font-size: 12px;") toolbar_layout.addWidget(outline_label) self.outline_white_btn = QPushButton("흰색") self.outline_white_btn.setCheckable(True) self.outline_white_btn.setFixedSize(50, 28) self.outline_white_btn.setCursor(Qt.PointingHandCursor) self.outline_white_btn.clicked.connect(lambda: self._set_outline_color("#ffffff")) self.outline_white_btn.setStyleSheet(""" QPushButton { background-color: #ffffff; color: #000000; border: 2px solid #475569; border-radius: 4px; font-size: 10px; } QPushButton:checked { border: 2px solid #3b82f6; } """) toolbar_layout.addWidget(self.outline_white_btn) self.outline_black_btn = QPushButton("검정") self.outline_black_btn.setCheckable(True) self.outline_black_btn.setFixedSize(50, 28) self.outline_black_btn.setCursor(Qt.PointingHandCursor) self.outline_black_btn.clicked.connect(lambda: self._set_outline_color("#000000")) self.outline_black_btn.setStyleSheet(""" QPushButton { background-color: #000000; color: #ffffff; border: 2px solid #475569; border-radius: 4px; font-size: 10px; } QPushButton:checked { border: 2px solid #3b82f6; } """) toolbar_layout.addWidget(self.outline_black_btn) self.outline_none_btn = QPushButton("없음") self.outline_none_btn.setCheckable(True) self.outline_none_btn.setChecked(True) self.outline_none_btn.setFixedSize(50, 28) self.outline_none_btn.setCursor(Qt.PointingHandCursor) self.outline_none_btn.clicked.connect(lambda: self._set_outline_color(None)) self.outline_none_btn.setStyleSheet(""" QPushButton { background-color: #334155; color: #94a3b8; border: 2px solid #475569; border-radius: 4px; font-size: 10px; } QPushButton:checked { border: 2px solid #3b82f6; color: #ffffff; } """) toolbar_layout.addWidget(self.outline_none_btn) # 돋보이기 버튼 그룹 (단일 선택) self.outline_group = QButtonGroup(self) self.outline_group.setExclusive(True) self.outline_group.addButton(self.outline_white_btn) self.outline_group.addButton(self.outline_black_btn) self.outline_group.addButton(self.outline_none_btn) toolbar_layout.addStretch() layout.addWidget(toolbar) def _create_command_panel(self, layout: QHBoxLayout): """오른쪽 명령어 패널 생성""" panel = QWidget() panel.setFixedWidth(100) panel.setStyleSheet("background-color: #1e293b; border-left: 1px solid #334155;") panel_layout = QVBoxLayout(panel) panel_layout.setContentsMargins(8, 16, 8, 16) panel_layout.setSpacing(8) # 저장 방식 라벨 save_mode_label = QLabel("저장방식") save_mode_label.setAlignment(Qt.AlignCenter) save_mode_label.setStyleSheet("color: #64748b; font-size: 10px; border: none;") panel_layout.addWidget(save_mode_label) # 저장 방식 선택 버튼 self.save_mode_group = QButtonGroup(self) self.save_mode_group.setExclusive(True) self.save_original_btn = QPushButton("원본저장") self.save_original_btn.setCheckable(True) self.save_original_btn.setChecked(True) self.save_original_btn.setCursor(Qt.PointingHandCursor) self.save_original_btn.clicked.connect(lambda: self._set_save_mode(self.SAVE_ORIGINAL)) self.save_original_btn.setStyleSheet(self._get_cmd_btn_style(True)) panel_layout.addWidget(self.save_original_btn) self.save_mode_group.addButton(self.save_original_btn) self.save_copy_btn = QPushButton("사본저장") self.save_copy_btn.setCheckable(True) self.save_copy_btn.setCursor(Qt.PointingHandCursor) self.save_copy_btn.clicked.connect(lambda: self._set_save_mode(self.SAVE_COPY)) self.save_copy_btn.setStyleSheet(self._get_cmd_btn_style(False)) panel_layout.addWidget(self.save_copy_btn) self.save_mode_group.addButton(self.save_copy_btn) # 구분선 sep = QFrame() sep.setFixedHeight(1) sep.setStyleSheet("background-color: #334155; border: none;") panel_layout.addWidget(sep) panel_layout.addStretch() # 삭제 버튼 delete_btn = QPushButton("🗑 객체삭제") delete_btn.setCursor(Qt.PointingHandCursor) delete_btn.clicked.connect(self.image_view.delete_selected) delete_btn.setStyleSheet(""" QPushButton { background-color: #475569; color: #ffffff; border: none; border-radius: 6px; padding: 10px 8px; font-size: 11px; } QPushButton:hover { background-color: #ef4444; } """) panel_layout.addWidget(delete_btn) # 초기화 버튼 clear_btn = QPushButton("↺ 초기화") clear_btn.setCursor(Qt.PointingHandCursor) clear_btn.clicked.connect(self.image_view.clear_edits) clear_btn.setStyleSheet(""" QPushButton { background-color: #475569; color: #ffffff; border: none; border-radius: 6px; padding: 10px 8px; font-size: 11px; } QPushButton:hover { background-color: #f97316; } """) panel_layout.addWidget(clear_btn) # 구분선 sep2 = QFrame() sep2.setFixedHeight(1) sep2.setStyleSheet("background-color: #334155; border: none;") panel_layout.addWidget(sep2) # 저장 버튼 save_btn = QPushButton("💾 저장") save_btn.setCursor(Qt.PointingHandCursor) save_btn.clicked.connect(self._save_image) save_btn.setStyleSheet(""" QPushButton { background-color: #22c55e; color: #ffffff; border: none; border-radius: 6px; padding: 12px 8px; font-size: 12px; font-weight: bold; } QPushButton:hover { background-color: #16a34a; } """) panel_layout.addWidget(save_btn) # 구분선 sep3 = QFrame() sep3.setFixedHeight(1) sep3.setStyleSheet("background-color: #334155; border: none;") panel_layout.addWidget(sep3) # 도움말 버튼 help_btn = QPushButton("❓ 도움말") help_btn.setCursor(Qt.PointingHandCursor) help_btn.setToolTip("단축키 및 기능 설명 [F1]") help_btn.clicked.connect(self._show_help) help_btn.setStyleSheet(""" QPushButton { background-color: #334155; color: #94a3b8; border: none; border-radius: 6px; padding: 10px 8px; font-size: 11px; } QPushButton:hover { background-color: #475569; color: #ffffff; } """) panel_layout.addWidget(help_btn) layout.addWidget(panel) def _show_help(self): """도움말 다이얼로그 표시""" help_text = """

📷 이미지 편집기 단축키

🔧 도구 선택

V선택/이동 도구
R사각형
E원/타원
L직선
A화살표
T텍스트
P자유선 (펜)
Space / ESC선택 모드로 전환

🎨 스타일

1~8빠른 색상 선택
[ 또는 -두께 감소
] 또는 +두께 증가
S그림자 효과 토글

📐 객체 조작

방향키선택 객체 1px 이동
Shift+방향키선택 객체 10px 이동
Delete선택 객체 삭제
Ctrl+A모두 선택
Ctrl+드래그객체 복사
Alt+그리기정사각형/원 그리기
우클릭컨텍스트 메뉴 (그룹/배치)

🔍 보기

Ctrl+휠확대/축소
이전/다음 이미지
더블클릭원본/맞춤 크기 토글
0창 크기에 맞춤
1원본 크기
← →이전/다음 이미지

💾 파일

Ctrl+S저장
Ctrl+Z실행 취소
Ctrl+Y다시 실행
ESC닫기 (선택 모드일 때)
F1도움말

🖱️ 마우스

""" msg = QMessageBox(self) msg.setWindowTitle("도움말") msg.setTextFormat(Qt.RichText) msg.setText(help_text) msg.setStyleSheet(""" QMessageBox { background-color: #1e293b; } QMessageBox QLabel { color: #f8fafc; font-size: 11px; } QPushButton { background-color: #3b82f6; color: white; border: none; border-radius: 4px; padding: 8px 16px; } QPushButton:hover { background-color: #2563eb; } """) msg.exec() def _get_cmd_btn_style(self, is_checked: bool) -> str: """명령어 패널 버튼 스타일""" if is_checked: return """ QPushButton { background-color: #3b82f6; color: #ffffff; border: none; border-radius: 4px; padding: 8px 4px; font-size: 10px; } """ else: return """ QPushButton { background-color: #334155; color: #94a3b8; border: none; border-radius: 4px; padding: 8px 4px; font-size: 10px; } QPushButton:hover { background-color: #475569; color: #ffffff; } """ def _create_separator(self) -> QFrame: """구분선 생성""" sep = QFrame() sep.setFixedWidth(1) sep.setStyleSheet("background-color: #475569;") return sep def _create_bottom_bar(self, layout: QVBoxLayout): """하단 바 생성""" bar = QWidget() bar.setFixedHeight(50) bar.setStyleSheet("background-color: #1a1a1a;") bar_layout = QHBoxLayout(bar) bar_layout.setContentsMargins(16, 8, 16, 8) prev_btn = QPushButton("◀ 이전") prev_btn.setCursor(Qt.PointingHandCursor) prev_btn.clicked.connect(self.image_view.prev_image) prev_btn.setStyleSheet(""" QPushButton { background-color: #334155; color: #ffffff; border: none; border-radius: 6px; padding: 8px 16px; } QPushButton:hover { background-color: #475569; } """) bar_layout.addWidget(prev_btn) bar_layout.addStretch() self.index_label = QLabel() self.index_label.setStyleSheet("color: #94a3b8; font-size: 13px;") bar_layout.addWidget(self.index_label) bar_layout.addStretch() next_btn = QPushButton("다음 ▶") next_btn.setCursor(Qt.PointingHandCursor) next_btn.clicked.connect(self.image_view.next_image) next_btn.setStyleSheet(""" QPushButton { background-color: #334155; color: #ffffff; border: none; border-radius: 6px; padding: 8px 16px; } QPushButton:hover { background-color: #475569; } """) bar_layout.addWidget(next_btn) help_label = QLabel("휠: 이전/다음 | Ctrl+휠: 확대/축소 | Ctrl+Z: 실행취소 | 꼭지점 드래그: 크기조절") help_label.setStyleSheet("color: #64748b; font-size: 11px; margin-left: 16px;") bar_layout.addWidget(help_label) layout.addWidget(bar) def _update_info(self): """정보 업데이트""" if not self.images: return current = self.image_view.current_index total = len(self.images) info = self.image_view.get_current_image_info() if info: info_text = f"📷 {info.get('filename', '')} | " if 'width' in info and 'height' in info: info_text += f"🖼 {info['width']} × {info['height']} | " if 'size' in info: info_text += f"📦 {info['size']}" self.info_label.setText(info_text) self.index_label.setText(f"{current + 1} / {total}") def _on_undo_redo_changed(self, can_undo: bool, can_redo: bool): """Undo/Redo 상태 변경""" self.undo_btn.setEnabled(can_undo) self.redo_btn.setEnabled(can_redo) def _on_color_changed(self, color: QColor): """색상 변경""" self.image_view.pen_color = color # 선택된 아이템도 업데이트 self.image_view.update_selected_color(color) def _set_quick_color(self, color_hex: str): """빠른 색상 설정""" color = QColor(color_hex) self.color_btn.set_color(color) self.image_view.pen_color = color # 선택된 아이템도 업데이트 self.image_view.update_selected_color(color) def _on_width_changed(self, value: int): """두께 변경""" self.image_view.pen_width = value # 선택된 아이템도 업데이트 self.image_view.update_selected_width(value) def _on_font_changed(self, font_name: str): """폰트 변경""" font = QFont(font_name, self.font_size_spin.value()) font.setBold(self.bold_btn.isChecked()) font.setUnderline(self.underline_btn.isChecked()) self.image_view.font = font # 선택된 텍스트 아이템 업데이트 for item in self.image_view.scene.selectedItems(): if isinstance(item, QGraphicsTextItem): item.setFont(font) def _on_font_size_changed(self, size: int): """폰트 크기 변경""" font = QFont(self.font_combo.currentText(), size) font.setBold(self.bold_btn.isChecked()) font.setUnderline(self.underline_btn.isChecked()) self.image_view.font = font # 선택된 텍스트 아이템 업데이트 for item in self.image_view.scene.selectedItems(): if isinstance(item, QGraphicsTextItem): item.setFont(font) def _on_bold_toggled(self, checked: bool): """굵게 토글""" self.image_view.font_bold = checked font = self.image_view.font font.setBold(checked) self.image_view.font = font # 선택된 텍스트 아이템 업데이트 for item in self.image_view.scene.selectedItems(): if isinstance(item, QGraphicsTextItem): f = item.font() f.setBold(checked) item.setFont(f) def _on_underline_toggled(self, checked: bool): """밑줄 토글""" self.image_view.font_underline = checked font = self.image_view.font font.setUnderline(checked) self.image_view.font = font # 선택된 텍스트 아이템 업데이트 for item in self.image_view.scene.selectedItems(): if isinstance(item, QGraphicsTextItem): f = item.font() f.setUnderline(checked) item.setFont(f) def _set_outline_color(self, color_hex: Optional[str]): """돋보이기(외곽선) 색상 설정""" if color_hex: self.image_view.text_outline_color = QColor(color_hex) else: self.image_view.text_outline_color = None # 선택된 텍스트 아이템에 외곽선 효과 적용 for item in self.image_view.scene.selectedItems(): if isinstance(item, OutlineTextItem): outline_color = QColor(color_hex) if color_hex else None item.set_outline_color(outline_color) elif isinstance(item, QGraphicsTextItem): # 기존 QGraphicsTextItem을 OutlineTextItem으로 교체 pass # 현재는 건너뜀 def _on_shadow_toggled(self, checked: bool): """그림자 효과 토글""" self.image_view.use_shadow = checked def _on_edit_changed(self, can_undo: bool, can_redo: bool): """편집 상태 변경""" # 편집 내용이 있으면 저장 필요 if can_undo or self.image_view.edit_items: self._is_saved = False def _set_save_mode(self, mode: str): """저장 방식 설정""" self.save_mode = mode self.save_original_btn.setStyleSheet( self._get_cmd_btn_style(mode == self.SAVE_ORIGINAL) ) self.save_copy_btn.setStyleSheet( self._get_cmd_btn_style(mode == self.SAVE_COPY) ) def _generate_default_filename(self) -> str: """기본 파일명 생성 (레코드 정보 기반)""" from datetime import date as date_type # 디버깅 로그 logger.debug("record_info: %s", self.record_info) parts = [] # 날짜 (발생일자) occurrence_date = self.record_info.get("occurrence_date") if occurrence_date: if isinstance(occurrence_date, date_type): parts.append(f"({occurrence_date.strftime('%Y%m%d')})") elif isinstance(occurrence_date, str): # 문자열일 경우 그대로 사용 parts.append(f"({occurrence_date.replace('-', '')})") # 열번 column_number = self.record_info.get("column_number", "") if column_number: parts.append(f"제{column_number}열차") # 편성번호 train_number = self.record_info.get("train_number", "") if train_number: parts.append(f"{train_number}편성") # 호차 car_number = self.record_info.get("car_number", "") if car_number: parts.append(f"{car_number}호차") # 장치분류 device_category = self.record_info.get("device_category", "") if device_category: parts.append(device_category) # 고장내용 (요약) fault_content = self.record_info.get("fault_content", "") if fault_content: summary = self._summarize_fault_content(fault_content) if summary: parts.append(summary) if parts: return " ".join(parts) return "사진" def _summarize_fault_content(self, content: str) -> str: """ 고장내용 요약 (NLP 도입 전 임시 메서드) 추후 NLP(자연어처리)를 도입하여 고장내용을 분석하고 핵심 키워드를 추출하는 방식으로 개선 예정 Args: content: 원본 고장내용 텍스트 Returns: 요약된 텍스트 (현재는 앞 20자 반환) """ # TODO: NLP 도입 시 이 메서드를 개선 # - 형태소 분석으로 핵심 명사 추출 # - 고장 유형 키워드 매칭 # - 위치 정보 추출 등 logger.debug("fault_content 원본: [%s]", content) if not content: logger.debug("fault_content가 비어있음") return "" # 현재는 단순히 앞부분만 사용 max_len = 20 if len(content) <= max_len: result = content else: result = content[:max_len] + "..." logger.debug("fault_content 요약: [%s]", result) return result def _get_save_path(self) -> str: """저장 경로 결정""" attachment = self.images[self.image_view.current_index] full_path = attachment.get_full_path() if self.save_mode == self.SAVE_ORIGINAL: # 원본 경로에 그대로 저장 return str(full_path) else: # 사본: 기본 파일명으로 새 파일 생성 default_name = self._generate_default_filename() suffix = full_path.suffix # 같은 폴더에 여러 장이면 번호 붙이기 base_path = full_path.parent / f"{default_name}{suffix}" if base_path.exists(): # 번호 붙이기 counter = 1 while True: numbered_path = full_path.parent / f"{default_name}_{counter}{suffix}" if not numbered_path.exists(): return str(numbered_path) counter += 1 return str(base_path) def _save_image(self): """이미지 저장""" if not self.image_view.edit_items: QMessageBox.information(self, "저장", "편집 내용이 없습니다.") return save_path = self._get_save_path() success = self.image_view.save_edited_image(save_path) if success: self._is_saved = True from pathlib import Path save_path_obj = Path(save_path) filename = save_path_obj.name # 썸네일 갱신 self._regenerate_thumbnail(save_path_obj) QMessageBox.information( self, "저장 완료", f"이미지가 저장되었습니다.\n\n{filename}" ) else: QMessageBox.warning(self, "저장 실패", "이미지 저장에 실패했습니다.") def _regenerate_thumbnail(self, image_path): """썸네일 재생성""" from pathlib import Path try: from PIL import Image, ImageOps image_path = Path(image_path) thumb_dir = image_path.parent / "thumbnails" thumb_dir.mkdir(exist_ok=True) thumb_path = thumb_dir / f"thumb_{image_path.name}" with Image.open(image_path) as img: # EXIF 회전 정보 적용 try: img = ImageOps.exif_transpose(img) except Exception: pass # 썸네일 크기 (150x150) img.thumbnail((150, 150), Image.Resampling.LANCZOS) # RGB로 변환 (PNG 알파 채널 처리) if img.mode in ('RGBA', 'P'): img = img.convert('RGB') img.save(thumb_path, 'JPEG', quality=85) logger.info("썸네일 갱신: %s", thumb_path) except Exception as e: logger.error("썸네일 갱신 실패: %s", e) def _ask_save_before_close(self) -> bool: """닫기 전 저장 여부 확인. True면 닫아도 됨""" if self._is_saved or not self.image_view.edit_items: return True reply = QMessageBox.question( self, "저장 확인", "편집 내용이 저장되지 않았습니다.\n저장하시겠습니까?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save ) if reply == QMessageBox.Save: self._save_image() return self._is_saved # 저장 성공 시에만 닫기 elif reply == QMessageBox.Discard: return True # 저장 안 하고 닫기 else: return False # 취소 - 닫지 않음 def closeEvent(self, event): """닫기 이벤트""" if self._ask_save_before_close(): event.accept() else: event.ignore() def keyPressEvent(self, event: QKeyEvent): """키 이벤트""" modifiers = event.modifiers() key = event.key() # Ctrl 단축키 if modifiers == Qt.ControlModifier: if key == Qt.Key_Z: self.image_view.undo() return elif key == Qt.Key_Y: self.image_view.redo() return elif key == Qt.Key_S: self._save_image() return elif key == Qt.Key_A: # 모두 선택 for item in self.image_view.edit_items: item.setSelected(True) return # 선택된 객체가 있을 때 방향키로 이동 selected = self.image_view.scene.selectedItems() if selected and key in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down): # Shift 누르면 10px, 아니면 1px step = 10 if modifiers & Qt.ShiftModifier else 1 dx, dy = 0, 0 if key == Qt.Key_Left: dx = -step elif key == Qt.Key_Right: dx = step elif key == Qt.Key_Up: dy = -step elif key == Qt.Key_Down: dy = step for item in selected: if item in self.image_view.edit_items: item.moveBy(dx, dy) return # Space 또는 ESC: 선택 모드로 전환 (편집 도구 사용 중일 때) if key == Qt.Key_Space or key == Qt.Key_Escape: if self.image_view.current_tool != EditTool.SELECT: self.tool_buttons[EditTool.SELECT].setChecked(True) self.image_view.set_tool(EditTool.SELECT) return elif key == Qt.Key_Escape: self.close() return # 일반 단축키 if key == Qt.Key_Escape: pass # 위에서 처리됨 elif key == Qt.Key_Delete: self.image_view.delete_selected() # 두께 조절: [ ] 또는 숫자패드 +/- elif key == Qt.Key_BracketRight or (key == Qt.Key_Plus and modifiers != Qt.ControlModifier): # 두께 증가 new_val = min(20, self.width_spin.value() + 1) self.width_spin.setValue(new_val) elif key == Qt.Key_BracketLeft or (key == Qt.Key_Minus and modifiers != Qt.ControlModifier): # 두께 감소 new_val = max(1, self.width_spin.value() - 1) self.width_spin.setValue(new_val) # 확대/축소: Ctrl + +/-는 위에서 처리 안함, 여기서는 이미지 없는 경우 대비 elif key == Qt.Key_0: self.image_view._fit_to_view() elif key == Qt.Key_1: self.image_view.show_original_size() # 도구 선택 elif key == Qt.Key_V: self.tool_buttons[EditTool.SELECT].setChecked(True) self.image_view.set_tool(EditTool.SELECT) elif key == Qt.Key_R: self.tool_buttons[EditTool.RECT].setChecked(True) self.image_view.set_tool(EditTool.RECT) elif key == Qt.Key_E: self.tool_buttons[EditTool.ELLIPSE].setChecked(True) self.image_view.set_tool(EditTool.ELLIPSE) elif key == Qt.Key_L: self.tool_buttons[EditTool.LINE].setChecked(True) self.image_view.set_tool(EditTool.LINE) elif key == Qt.Key_A and modifiers != Qt.ControlModifier: self.tool_buttons[EditTool.ARROW].setChecked(True) self.image_view.set_tool(EditTool.ARROW) elif key == Qt.Key_T: self.tool_buttons[EditTool.TEXT].setChecked(True) self.image_view.set_tool(EditTool.TEXT) elif key == Qt.Key_P: self.tool_buttons[EditTool.FREEHAND].setChecked(True) self.image_view.set_tool(EditTool.FREEHAND) elif key == Qt.Key_S and modifiers != Qt.ControlModifier: # 그림자 토글 (Ctrl+S는 저장) self.shadow_btn.setChecked(not self.shadow_btn.isChecked()) self._on_shadow_toggled(self.shadow_btn.isChecked()) # 숫자 키로 색상 선택 (1-8) elif key in (Qt.Key_1, Qt.Key_2, Qt.Key_3, Qt.Key_4, Qt.Key_5, Qt.Key_6, Qt.Key_7, Qt.Key_8) and modifiers == Qt.NoModifier: color_index = key - Qt.Key_1 # 0-7 if 0 <= color_index < len(self.quick_colors): color_hex = self.quick_colors[color_index][0] self._set_quick_color(color_hex) # 도움말 elif key == Qt.Key_F1: self._show_help() # 이전/다음 이미지 (객체 선택 안 된 경우에만) elif key == Qt.Key_Left: self.image_view.prev_image() elif key == Qt.Key_Right: self.image_view.next_image() else: super().keyPressEvent(event) def mousePressEvent(self, event: QMouseEvent): """마우스 누름 이벤트 (창 드래그)""" if event.button() == Qt.LeftButton: self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() super().mousePressEvent(event) def mouseMoveEvent(self, event: QMouseEvent): """마우스 이동 이벤트""" if event.buttons() == Qt.LeftButton and hasattr(self, '_drag_pos'): self.move(event.globalPosition().toPoint() - self._drag_pos) super().mouseMoveEvent(event)