handOver2/ui/dialogs/image_viewer_dialog.py

2797 lines
101 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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)