2797 lines
101 KiB
Python
2797 lines
101 KiB
Python
# -*- 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 = """
|
||
<h2>📷 이미지 편집기 단축키</h2>
|
||
|
||
<h3>🔧 도구 선택</h3>
|
||
<table>
|
||
<tr><td><b>V</b></td><td>선택/이동 도구</td></tr>
|
||
<tr><td><b>R</b></td><td>사각형</td></tr>
|
||
<tr><td><b>E</b></td><td>원/타원</td></tr>
|
||
<tr><td><b>L</b></td><td>직선</td></tr>
|
||
<tr><td><b>A</b></td><td>화살표</td></tr>
|
||
<tr><td><b>T</b></td><td>텍스트</td></tr>
|
||
<tr><td><b>P</b></td><td>자유선 (펜)</td></tr>
|
||
<tr><td><b>Space / ESC</b></td><td>선택 모드로 전환</td></tr>
|
||
</table>
|
||
|
||
<h3>🎨 스타일</h3>
|
||
<table>
|
||
<tr><td><b>1~8</b></td><td>빠른 색상 선택</td></tr>
|
||
<tr><td><b>[ 또는 -</b></td><td>두께 감소</td></tr>
|
||
<tr><td><b>] 또는 +</b></td><td>두께 증가</td></tr>
|
||
<tr><td><b>S</b></td><td>그림자 효과 토글</td></tr>
|
||
</table>
|
||
|
||
<h3>📐 객체 조작</h3>
|
||
<table>
|
||
<tr><td><b>방향키</b></td><td>선택 객체 1px 이동</td></tr>
|
||
<tr><td><b>Shift+방향키</b></td><td>선택 객체 10px 이동</td></tr>
|
||
<tr><td><b>Delete</b></td><td>선택 객체 삭제</td></tr>
|
||
<tr><td><b>Ctrl+A</b></td><td>모두 선택</td></tr>
|
||
<tr><td><b>Ctrl+드래그</b></td><td>객체 복사</td></tr>
|
||
<tr><td><b>Alt+그리기</b></td><td>정사각형/원 그리기</td></tr>
|
||
<tr><td><b>우클릭</b></td><td>컨텍스트 메뉴 (그룹/배치)</td></tr>
|
||
</table>
|
||
|
||
<h3>🔍 보기</h3>
|
||
<table>
|
||
<tr><td><b>Ctrl+휠</b></td><td>확대/축소</td></tr>
|
||
<tr><td><b>휠</b></td><td>이전/다음 이미지</td></tr>
|
||
<tr><td><b>더블클릭</b></td><td>원본/맞춤 크기 토글</td></tr>
|
||
<tr><td><b>0</b></td><td>창 크기에 맞춤</td></tr>
|
||
<tr><td><b>1</b></td><td>원본 크기</td></tr>
|
||
<tr><td><b>← →</b></td><td>이전/다음 이미지</td></tr>
|
||
</table>
|
||
|
||
<h3>💾 파일</h3>
|
||
<table>
|
||
<tr><td><b>Ctrl+S</b></td><td>저장</td></tr>
|
||
<tr><td><b>Ctrl+Z</b></td><td>실행 취소</td></tr>
|
||
<tr><td><b>Ctrl+Y</b></td><td>다시 실행</td></tr>
|
||
<tr><td><b>ESC</b></td><td>닫기 (선택 모드일 때)</td></tr>
|
||
<tr><td><b>F1</b></td><td>도움말</td></tr>
|
||
</table>
|
||
|
||
<h3>🖱️ 마우스</h3>
|
||
<ul>
|
||
<li>드래그: 도형 그리기 또는 객체 이동</li>
|
||
<li>꼭지점 드래그: 도형 크기 조절</li>
|
||
<li>중간 버튼 드래그: 화면 이동</li>
|
||
</ul>
|
||
"""
|
||
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)
|