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