Vintage appMaker의 Tech Blog

pdf, jpg 변환 유틸리티를 TUI로 관리 본문

Source code or Tip/python

pdf, jpg 변환 유틸리티를 TUI로 관리

VintageappMaker 2026. 6. 18. 11:43

cnvt.py 개발자 매뉴얼

1. 사용목적

cnvt.py는 터미널에서 실행하는 파일 변환 관리 프로그램이다. 실행하면 방향키로 조작하는 콘솔 메뉴가 표시되며, 다음 두 가지 기능을 제공한다.

  • JPG 변환 폴더 감시: 지정한 폴더를 감시하다가 PNG, BMP, TIFF, TIF 파일이 생성되거나 변경되면 같은 위치에 JPG 파일로 자동 변환한다.
  • PDF 변환: PDF 파일에 대각선 워터마크를 적용한 *_watermarked_no_print.pdf 파일을 만든 뒤, 각 페이지를 1.jpg, 2.jpg 형식의 JPG 이미지로 저장한다.

주요 특징은 다음과 같다.

  • Windows 콘솔 ANSI 색상 출력을 지원한다.
  • 마우스 없이 방향키, Enter, Esc, Backspace로 파일과 폴더를 선택한다.
  • 이미지 감시 기능은 watchdog을 사용해 파일 생성, 수정, 이동 이벤트를 처리한다.
  • 이미지 변환은 Pillow를 사용한다.
  • PDF 워터마크는 reportlabPyPDF2를 사용한다.
  • PDF 페이지 렌더링은 PyMuPDFPillow를 사용한다.
  • 워터마크 PDF는 인쇄 권한을 제외한 제한 권한으로 암호화 저장한다.

2. 패키지설치 및 실행

권장 실행 환경

  • Python 3.9 이상
  • Windows PowerShell 또는 Windows Terminal 권장
  • Linux/macOS에서도 대부분 동작하지만, Windows 콘솔 키 입력과 ANSI 처리에 맞춘 코드가 포함되어 있다.

패키지 설치

전체 기능을 사용하려면 다음 패키지가 필요하다.

python -m pip install Pillow watchdog PyPDF2 reportlab PyMuPDF

기능별 최소 패키지는 다음과 같다.

# JPG 변환 폴더 감시
python -m pip install Pillow watchdog

# PDF 워터마크 및 JPG 변환
python -m pip install PyPDF2 reportlab PyMuPDF Pillow

실행

cnvt.py가 있는 폴더에서 다음 명령을 실행한다.

python cnvt.py

실행 후 메뉴에서 다음 키를 사용한다.

  • ↑ / ↓: 메뉴 또는 파일 목록 이동
  • Enter: 선택 항목 실행
  • Backspace: 파일 선택 화면에서 상위 폴더로 이동
  • Esc: 선택 취소 또는 종료
  • q: 메인 메뉴 종료
  • 1: JPG 변환 폴더 감시 바로 실행
  • 2: PDF 변환 바로 실행
  • 0: 종료

JPG 변환 폴더 감시 실행 흐름

  1. 메뉴에서 JPG 변환 폴더 감시를 선택한다.
  2. 감시할 폴더를 선택한다.
  3. 해당 폴더에 .png, .bmp, .tiff, .tif 파일이 생기거나 변경되면 JPG로 변환한다.
  4. 변환 결과는 원본 파일과 같은 폴더에 같은 이름의 .jpg 파일로 저장된다.
  5. Ctrl+C를 누르면 감시를 종료하고 이전 메뉴로 돌아간다.

PDF 변환 실행 흐름

  1. 메뉴에서 PDF 변환을 선택한다.
  2. 변환할 PDF 파일을 선택한다.
  3. 워터마크 문구를 입력한다.
  4. 워터마크 투명도 값을 입력한다. 기본값은 0.2이며 범위는 0.0 ~ 1.0이다.
  5. JPG 품질 값을 입력한다. 기본값은 80이며 범위는 1 ~ 100이다.
  6. JPG 저장 폴더를 선택한다. 기본값은 PDF 파일이 있는 폴더의 images 하위 폴더다.
  7. 원본 PDF와 같은 폴더에 원본파일명_watermarked_no_print.pdf가 저장된다.
  8. 워터마크 PDF의 각 페이지가 선택한 폴더에 1.jpg, 2.jpg 형식으로 저장된다.

3. 소스코드 예제설명

프로그램 상수

APP_TITLE = "변환 관리 프로그램"
SUPPORTED_IMAGE_EXTENSIONS = (".png", ".bmp", ".tiff", ".tif")

APP_TITLE은 콘솔 화면 상단에 표시되는 프로그램 이름이다. SUPPORTED_IMAGE_EXTENSIONS는 폴더 감시 기능에서 JPG 변환 대상으로 인정하는 확장자 목록이다.

콘솔 출력 유틸리티

class Console:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    ...

Console 클래스는 ANSI 색상, 화면 지우기, 커서 이동, 프레임 렌더링을 담당한다. enable_windows_ansi()는 Windows 콘솔에서도 ANSI 색상이 표시되도록 콘솔 모드를 설정한다.

파일과 폴더 선택 UI

def select_path(prompt, path_type, start_dir=None, default_path=None):
    current_dir = (start_dir or Path.cwd()).expanduser().resolve()
    ...

select_path()는 터미널 안에서 파일 탐색기처럼 동작하는 선택 UI다. path_type"file"이면 파일을 선택하고, "directory"이면 폴더를 선택한다. 방향키, PageUp, PageDown, Backspace, Enter, Esc 입력을 처리한다.

메인 메뉴

def draw_menu(selected_index, frame):
    items = [
        MenuItem("1", "JPG 변환 폴더 감시", "PNG/BMP/TIFF 파일이 생기면 JPG로 자동 변환"),
        MenuItem("2", "PDF 변환", "워터마크 PDF 생성 후 페이지별 JPG 저장"),
        MenuItem("0", "종료", "프로그램 종료"),
    ]

draw_menu()는 화면에 메뉴를 그리고, menu_loop()는 사용자의 키 입력을 받아 선택된 메뉴 번호를 반환한다.

작업 진행 화면

class Activity:
    def __init__(self, title):
        self.title = title
        self.messages = []
        ...

Activity는 작업 로그를 별도 스레드에서 주기적으로 갱신해 보여준다. PDF 변환이나 폴더 감시처럼 시간이 걸리는 작업의 진행 상태를 화면에 표시하는 데 사용된다.

이미지 JPG 변환

def convert_image_to_jpg(source_path):
    from PIL import Image

    destination = source_path.with_suffix(".jpg")
    with Image.open(source_path) as image:
        image.convert("RGB").save(destination, "JPEG", quality=80)
    return destination

원본 이미지를 열고 RGB 모드로 변환한 뒤 JPEG 품질 80으로 저장한다. PNG의 투명 채널이나 TIFF의 다양한 색상 모드는 JPG 저장 전에 RGB로 변환된다.

파일 쓰기 완료 대기

def wait_until_file_ready(path, timeout=15.0, stable_seconds=0.6):
    ...

감시 대상 폴더에 파일이 생성되었더라도 아직 복사 중일 수 있다. 이 함수는 파일 크기가 일정 시간 안정적으로 유지되고 읽기 가능한 상태가 될 때까지 기다린다.

폴더 감시 기반 JPG 변환

def start_jpg_converter():
    ...
    observer.schedule(ImageAutoConverterHandler(), str(target_dir), recursive=False)

start_jpg_converter()는 사용자가 선택한 폴더를 watchdog.Observer로 감시한다. 생성, 수정, 이동 이벤트가 발생하면 큐에 넣고, 중복 변환을 막기 위해 pending 집합으로 처리 중인 파일을 관리한다.

워터마크 페이지 생성

def build_watermark_page(width, height, text, opacity):
    from PyPDF2 import PdfReader
    from reportlab.lib.colors import Color
    from reportlab.pdfgen import canvas
    ...

reportlab으로 메모리 안에 단일 페이지 PDF를 만들고, 페이지 중앙에 대각선 워터마크 문구를 그린다. 생성된 PDF 페이지는 PyPDF2.PdfReader로 읽어 원본 PDF 페이지와 병합할 수 있는 객체로 반환된다.

PDF 워터마크 적용

def apply_watermark(input_pdf, output_pdf, text, opacity, progress=None):
    ...
    page.merge_page(watermark)
    writer.add_page(page)

원본 PDF의 각 페이지 크기에 맞는 워터마크 페이지를 만들고 병합한다. 저장 시 writer.encrypt()를 호출해 인쇄를 제외한 일부 권한만 허용하도록 설정한다.

PDF 페이지 JPG 저장

def convert_pdf_to_jpg(pdf_path, output_dir, quality=80, scale=2.0, progress=None):
    import fitz
    from PIL import Image
    ...

PyMuPDFfitz로 PDF 페이지를 렌더링하고, Pillow 이미지로 변환한 뒤 JPG 파일로 저장한다. scale=2.0은 원본 PDF 좌표 대비 2배 해상도로 렌더링한다는 뜻이다.

프로그램 진입점

def main():
    Console.configure_output_encoding()
    Console.enable_windows_ansi()
    ...

if __name__ == "__main__":
    main()

main()은 출력 인코딩과 ANSI 색상 처리를 준비하고, 메인 메뉴 루프를 반복 실행한다. 사용자가 종료를 선택하면 커서 표시와 콘솔 스타일을 원래 상태로 되돌린다.

4. 전체소스코드

import io
import math
import os
import queue
import shutil
import sys
import threading
import time
import unicodedata
from dataclasses import dataclass
from pathlib import Path


APP_TITLE = "변환 관리 프로그램"
SUPPORTED_IMAGE_EXTENSIONS = (".png", ".bmp", ".tiff", ".tif")


class Console:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    DIM = "\033[2m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    BLUE = "\033[34m"
    CYAN = "\033[36m"
    WHITE = "\033[37m"
    HIDE_CURSOR = "\033[?25l"
    SHOW_CURSOR = "\033[?25h"
    CLEAR_TO_END = "\033[J"

    @staticmethod
    def configure_output_encoding():
        for stream in (sys.stdout, sys.stderr):
            if hasattr(stream, "reconfigure"):
                stream.reconfigure(encoding="utf-8", errors="replace")

    @staticmethod
    def enable_windows_ansi():
        if os.name != "nt":
            return
        try:
            import ctypes

            kernel32 = ctypes.windll.kernel32
            handle = kernel32.GetStdHandle(-11)
            mode = ctypes.c_uint32()
            if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
                kernel32.SetConsoleMode(handle, mode.value | 0x0004)
        except Exception:
            pass

    @staticmethod
    def clear():
        print("\033[2J\033[H", end="")

    @staticmethod
    def move_home():
        print("\033[H", end="")

    @staticmethod
    def render_frame(lines):
        sys.stdout.write("\033[H")
        sys.stdout.write("\033[K\n".join(lines))
        sys.stdout.write("\033[K")
        sys.stdout.write(Console.CLEAR_TO_END)
        sys.stdout.flush()

    @staticmethod
    def color(text, color):
        return f"{color}{text}{Console.RESET}"


@dataclass
class MenuItem:
    key: str
    title: str
    description: str


def dependency_error(package_name, install_name=None):
    install = install_name or package_name
    print(Console.color(f"필요한 Python 패키지를 찾을 수 없습니다: {package_name}", Console.RED))
    print(f"설치 예: python -m pip install {install}")


def wait_for_key(message="계속하려면 아무 키나 누르세요."):
    print()
    print(Console.color(message, Console.DIM))
    read_key()


def read_key(timeout=None):
    if os.name == "nt":
        import msvcrt

        if timeout is not None:
            deadline = time.monotonic() + timeout
            while time.monotonic() < deadline:
                if msvcrt.kbhit():
                    break
                time.sleep(0.01)
            else:
                return None

        key = msvcrt.getwch()
        if key in ("\x00", "\xe0"):
            key += msvcrt.getwch()
        return key

    if timeout is not None:
        import select

        readable, _, _ = select.select([sys.stdin], [], [], timeout)
        if not readable:
            return None

    import termios
    import tty

    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        return sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


def prompt_line(prompt, default=None, required=False):
    while True:
        suffix = f" [{default}]" if default is not None else ""
        value = input(f"{prompt}{suffix}: ").strip().strip('"')
        if not value and default is not None:
            return str(default)
        if value or not required:
            return value
        print(Console.color("값을 입력하세요.", Console.YELLOW))


def is_up_key(key):
    return key in ("\xe0H", "\x1b[A")


def is_down_key(key):
    return key in ("\xe0P", "\x1b[B")


def is_right_key(key):
    return key in ("\xe0M", "\x1b[C")


def is_page_up_key(key):
    return key in ("\xe0I", "\x1b[5~")


def is_page_down_key(key):
    return key in ("\xe0Q", "\x1b[6~")


def available_windows_drives():
    if os.name != "nt":
        return []
    drives = []
    for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
        drive = Path(f"{letter}:\\")
        if drive.exists():
            drives.append(drive)
    return drives


def path_menu_entries(current_dir, path_type):
    entries = []
    if current_dir.parent != current_dir:
        entries.append(("parent", current_dir.parent, "⬆️  .."))
    elif os.name == "nt":
        for drive in available_windows_drives():
            entries.append(("directory", drive, f"💽 {drive}"))

    if path_type == "directory":
        entries.append(("select_directory", current_dir, "✅ 현재 폴더 선택"))

    try:
        children = sorted(
            current_dir.iterdir(),
            key=lambda path: (not path.is_dir(), path.name.lower()),
        )
    except OSError:
        children = []

    for child in children:
        if child.is_dir():
            entries.append(("directory", child, f"📁 {child.name}"))
        elif path_type == "file" and child.is_file():
            entries.append(("file", child, f"📄 {child.name}"))
    return entries


def select_path(prompt, path_type, start_dir=None, default_path=None):
    current_dir = (start_dir or Path.cwd()).expanduser().resolve()
    if current_dir.is_file():
        current_dir = current_dir.parent
    selected = 0
    scroll = 0
    frame = 0
    Console.clear()

    while True:
        entries = path_menu_entries(current_dir, path_type)
        if default_path is not None:
            entries.insert(0, ("default", Path(default_path).expanduser(), f"⭐ 기본값 사용: {default_path}"))
        if not entries:
            entries = [("empty", current_dir, "선택 가능한 항목이 없습니다.")]

        selected = min(selected, len(entries) - 1)
        terminal_size = shutil.get_terminal_size((80, 24))
        width = min(max(terminal_size.columns - 4, 54), 110)
        list_height = max(6, terminal_size.lines - 10)
        if selected < scroll:
            scroll = selected
        elif selected >= scroll + list_height:
            scroll = selected - list_height + 1

        animation = ["🧭", "🔎", "📂", "✨"][frame % 4]
        frame += 1
        mode_label = "파일 선택" if path_type == "file" else "폴더 선택"
        lines = header_lines(f"{animation} {prompt}")
        lines.append("")
        lines.extend(box_lines(mode_label, [f"현재 위치: {current_dir}"], width))
        lines.append("")

        visible_entries = entries[scroll : scroll + list_height]
        lines.append(Console.color(f"┌{'─' * (width - 2)}┐", Console.CYAN))
        for index, (entry_type, path, label) in enumerate(visible_entries, start=scroll):
            selected_entry = index == selected
            marker = "👉 " if selected_entry else "   "
            suffix = ""
            if entry_type in ("file", "select_directory", "default"):
                suffix = "  Enter"
            elif path_type == "directory" and entry_type in ("directory", "parent"):
                suffix = ""
            elif entry_type in ("directory", "parent"):
                suffix = ""
            line = fit_display_width(f"{marker}{label}{suffix}", width - 4)
            color = Console.GREEN if selected_entry else Console.WHITE if entry_type != "empty" else Console.DIM
            border = Console.GREEN if selected_entry else Console.CYAN
            lines.append(f"{Console.color('│', border)} {Console.color(line, color)} {Console.color('│', border)}")
        lines.append(Console.color(f"└{'─' * (width - 2)}┘", Console.CYAN))

        lines.append("")
        lines.append(Console.color(" ↑/↓ 이동  Enter 실행  Backspace 상위 폴더  Esc 취소", Console.DIM))
        Console.render_frame(lines)

        key = read_key()
        if is_up_key(key):
            selected = (selected - 1) % len(entries)
        elif is_down_key(key):
            selected = (selected + 1) % len(entries)
        elif is_page_up_key(key):
            selected = max(0, selected - list_height)
        elif is_page_down_key(key):
            selected = min(len(entries) - 1, selected + list_height)
        elif key in ("\b", "\x7f"):
            if current_dir.parent != current_dir:
                current_dir = current_dir.parent
                selected = 0
                scroll = 0
        elif is_right_key(key):
            entry_type, path, _ = entries[selected]
            if entry_type in ("directory", "parent"):
                current_dir = path.resolve()
                selected = 0
                scroll = 0
        elif key == "\x1b":
            return None
        elif key in ("\r", "\n"):
            entry_type, path, _ = entries[selected]
            if entry_type == "default":
                Console.clear()
                return path.resolve()
            if entry_type == "select_directory":
                Console.clear()
                return path.resolve()
            if entry_type == "parent":
                current_dir = path.resolve()
                selected = 0
                scroll = 0
                continue
            if entry_type in ("directory", "parent"):
                current_dir = path.resolve()
                selected = 0
                scroll = 0
            elif entry_type == "file":
                Console.clear()
                return path.resolve()


def prompt_existing_path(prompt, path_type):
    while True:
        path = select_path(prompt, path_type)
        if path is None:
            print()
            print(Console.color("경로 선택이 취소되었습니다. 다시 선택하세요.", Console.YELLOW))
            wait_for_key()
            Console.clear()
            continue
        if path_type == "file" and path.is_file():
            return path
        if path_type == "directory" and path.is_dir():
            return path
        print(Console.color(f"경로가 올바르지 않습니다: {path}", Console.YELLOW))


def prompt_float(prompt, default, minimum, maximum):
    while True:
        raw = prompt_line(prompt, default=default)
        try:
            value = float(raw)
        except ValueError:
            print(Console.color(f"{minimum} 이상 {maximum} 이하의 숫자를 입력하세요.", Console.YELLOW))
            continue
        if minimum <= value <= maximum:
            return value
        print(Console.color(f"{minimum} 이상 {maximum} 이하의 숫자를 입력하세요.", Console.YELLOW))


def prompt_int(prompt, default, minimum, maximum):
    while True:
        raw = prompt_line(prompt, default=default)
        try:
            value = int(raw)
        except ValueError:
            print(Console.color(f"{minimum} 이상 {maximum} 이하의 정수를 입력하세요.", Console.YELLOW))
            continue
        if minimum <= value <= maximum:
            return value
        print(Console.color(f"{minimum} 이상 {maximum} 이하의 정수를 입력하세요.", Console.YELLOW))


def draw_header(subtitle=""):
    width = min(max(shutil.get_terminal_size((80, 24)).columns, 54), 100)
    line = "=" * width
    print(Console.color(line, Console.CYAN))
    print(Console.color(f" {APP_TITLE}", Console.BOLD + Console.WHITE))
    if subtitle:
        print(Console.color(f" {subtitle}", Console.DIM))
    print(Console.color(line, Console.CYAN))


def header_lines(subtitle=""):
    width = min(max(shutil.get_terminal_size((80, 24)).columns, 54), 100)
    line = "=" * width
    lines = [
        Console.color(line, Console.CYAN),
        Console.color(f" {APP_TITLE}", Console.BOLD + Console.WHITE),
    ]
    if subtitle:
        lines.append(Console.color(f" {subtitle}", Console.DIM))
    lines.append(Console.color(line, Console.CYAN))
    return lines


def fit_display_width(text, width, fill=" "):
    result = []
    current_width = 0
    for char in text:
        char_width = 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in ("F", "W") else 1
        if current_width + char_width > width:
            break
        result.append(char)
        current_width += char_width
    return "".join(result) + fill * max(0, width - current_width)


def box_lines(title, body_lines, width, selected=False):
    border_color = Console.GREEN if selected else Console.CYAN
    title_color = Console.GREEN if selected else Console.WHITE
    inner_width = max(10, width - 4)
    top = f"┌{'─' * (width - 2)}┐"
    bottom = f"└{'─' * (width - 2)}┘"
    lines = [Console.color(top, border_color)]
    for index, line in enumerate([title, *body_lines]):
        visible = fit_display_width(line, inner_width)
        content_color = title_color if index == 0 else Console.WHITE if selected else Console.DIM
        lines.append(f"{Console.color('│', border_color)} {Console.color(visible, content_color)} {Console.color('│', border_color)}")
    lines.append(Console.color(bottom, border_color))
    return lines


def draw_box(title, body_lines, width=None):
    terminal_width = shutil.get_terminal_size((80, 24)).columns
    width = width or min(max(terminal_width - 4, 54), 100)
    for line in box_lines(title, body_lines, width):
        print(line)


def draw_menu(selected_index, frame):
    items = [
        MenuItem("1", "JPG 변환 폴더 감시", "PNG/BMP/TIFF 파일이 생기면 JPG로 자동 변환"),
        MenuItem("2", "PDF 변환", "워터마크 PDF 생성 후 페이지별 JPG 저장"),
        MenuItem("0", "종료", "프로그램 종료"),
    ]
    animation = ["🌕", "🌖", "🌗", "🌘", "🌑", "🌒", "🌓", "🌔"][frame % 8]
    terminal_width = shutil.get_terminal_size((80, 24)).columns
    menu_width = min(max(terminal_width - 4, 54), 100)
    lines = header_lines(f"{animation} 방향키 또는 숫자 키로 선택, Enter 실행")
    lines.append("")
    for index, item in enumerate(items):
        selected = index == selected_index
        marker = "👉" if selected else "  "
        title = f"{marker} [{item.key}] {item.title}"
        lines.extend(box_lines(title, [item.description], menu_width, selected=selected))
        lines.append("")
    lines.append(Console.color(" q 또는 Esc: 종료", Console.DIM))
    Console.render_frame(lines)


def menu_loop():
    selected = 0
    frame = 0
    key_to_index = {"1": 0, "2": 1, "0": 2}
    Console.clear()

    while True:
        draw_menu(selected, frame)
        frame += 1
        key = read_key(timeout=0.15)
        if key is None:
            continue

        if key in ("\r", "\n"):
            return ["1", "2", "0"][selected]
        if key in ("q", "Q", "\x1b"):
            return "0"
        if key in key_to_index:
            return key
        if is_up_key(key):
            selected = (selected - 1) % 3
        elif is_down_key(key):
            selected = (selected + 1) % 3


class Activity:
    def __init__(self, title):
        self.title = title
        self.messages = []
        self.running = False
        self.thread = None
        self.frame = 0

    def log(self, message):
        timestamp = time.strftime("%H:%M:%S")
        self.messages.append(f"[{timestamp}] {message}")
        self.messages = self.messages[-12:]

    def start(self):
        self.running = True
        self.thread = threading.Thread(target=self._render_loop, daemon=True)
        self.thread.start()

    def stop(self):
        self.running = False
        if self.thread:
            self.thread.join(timeout=1.0)

    def _render_loop(self):
        while self.running:
            self.render()
            time.sleep(0.15)

    def render(self):
        animation = ["⏳", "⌛", "🔄", "✨"][self.frame % 4]
        self.frame += 1
        lines = header_lines(f"{animation} {self.title}")
        lines.append("")
        terminal_width = shutil.get_terminal_size((80, 24)).columns
        box_width = min(max(terminal_width - 4, 54), 100)
        body = self.messages[:] or ["작업을 준비하는 중입니다."]
        lines.extend(box_lines("작업 로그", body, box_width))
        lines.append("")
        lines.append(Console.color("Ctrl+C를 누르면 이전 메뉴로 돌아갑니다.", Console.DIM))
        Console.render_frame(lines)


def convert_image_to_jpg(source_path):
    try:
        from PIL import Image
    except ImportError:
        dependency_error("Pillow", "Pillow")
        return False

    destination = source_path.with_suffix(".jpg")
    with Image.open(source_path) as image:
        image.convert("RGB").save(destination, "JPEG", quality=80)
    return destination


def is_supported_image_path(path):
    return Path(path).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS


def wait_until_file_ready(path, timeout=15.0, stable_seconds=0.6):
    deadline = time.monotonic() + timeout
    last_size = None
    stable_since = None

    while time.monotonic() < deadline:
        try:
            if not path.is_file():
                time.sleep(0.15)
                continue
            size = path.stat().st_size
            with path.open("rb") as handle:
                handle.read(1)
        except OSError:
            last_size = None
            stable_since = None
            time.sleep(0.15)
            continue

        now = time.monotonic()
        if size == last_size:
            stable_since = stable_since or now
            if now - stable_since >= stable_seconds:
                return True
        else:
            last_size = size
            stable_since = now
        time.sleep(0.15)

    return False


def start_jpg_converter():
    Console.clear()
    draw_header("JPG 변환 폴더 감시")
    print()
    target_dir = prompt_existing_path("감시할 폴더 경로를 입력하세요", "directory")

    try:
        from watchdog.events import FileSystemEventHandler
        from watchdog.observers import Observer
    except ImportError:
        dependency_error("watchdog", "watchdog")
        wait_for_key()
        return

    events = queue.Queue()
    pending = set()
    pending_lock = threading.Lock()

    def enqueue_image(source, reason):
        source = Path(source)
        if not is_supported_image_path(source):
            return
        with pending_lock:
            if source in pending:
                return
            pending.add(source)
        events.put((source, reason))

    class ImageAutoConverterHandler(FileSystemEventHandler):
        def on_created(self, event):
            if event.is_directory:
                return
            enqueue_image(event.src_path, "생성")

        def on_modified(self, event):
            if event.is_directory:
                return
            enqueue_image(event.src_path, "변경")

        def on_moved(self, event):
            if event.is_directory:
                return
            enqueue_image(event.dest_path, "이동")

    activity = Activity(f"감시 중: {target_dir}")
    activity.log("새 이미지 파일을 기다리는 중입니다.")
    observer = Observer()
    observer.schedule(ImageAutoConverterHandler(), str(target_dir), recursive=False)

    try:
        observer.start()
        activity.start()
        for source in target_dir.iterdir():
            if source.is_file() and is_supported_image_path(source):
                enqueue_image(source, "기존 파일")
        while True:
            try:
                source, reason = events.get(timeout=0.2)
            except queue.Empty:
                continue
            try:
                activity.log(f"{reason} 감지: {source.name}")
                if not wait_until_file_ready(source):
                    activity.log(f"변환 보류: 파일을 읽을 수 없습니다 ({source.name})")
                    continue
                destination = convert_image_to_jpg(source)
                activity.log(f"변환 완료: {source.name} -> {destination.name}")
            except Exception as exc:
                activity.log(f"변환 실패: {source.name} ({exc})")
            finally:
                with pending_lock:
                    pending.discard(source)
    except KeyboardInterrupt:
        activity.log("감시를 종료합니다.")
    finally:
        observer.stop()
        observer.join()
        activity.stop()


def build_watermark_page(width, height, text, opacity):
    from PyPDF2 import PdfReader
    from reportlab.lib.colors import Color
    from reportlab.pdfgen import canvas

    packet = io.BytesIO()
    pdf = canvas.Canvas(packet, pagesize=(width, height))
    pdf.saveState()
    pdf.setFillColor(Color(0.35, 0.35, 0.35, alpha=opacity))
    font_size = max(32, min(width, height) / 9)
    pdf.setFont("Helvetica-Bold", font_size)
    rotation = math.degrees(math.atan2(height, width))
    pdf.translate(width / 2, height / 2)
    pdf.rotate(rotation)
    pdf.drawCentredString(0, -font_size * 0.35, text)
    pdf.restoreState()
    pdf.showPage()
    pdf.save()
    packet.seek(0)
    return PdfReader(packet).pages[0]


def apply_watermark(input_pdf, output_pdf, text, opacity, progress=None):
    from PyPDF2 import PdfReader, PdfWriter
    from PyPDF2.constants import UserAccessPermissions

    reader = PdfReader(str(input_pdf))
    writer = PdfWriter()
    permissions = (
        UserAccessPermissions.EXTRACT
        | UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS
        | UserAccessPermissions.FILL_FORM_FIELDS
        | UserAccessPermissions.ASSEMBLE_DOC
    )

    page_count = len(reader.pages)
    for index, page in enumerate(reader.pages, start=1):
        width = float(page.mediabox.width)
        height = float(page.mediabox.height)
        watermark = build_watermark_page(width, height, text, opacity)
        page.merge_page(watermark)
        writer.add_page(page)
        if progress:
            progress(f"워터마크 적용 중: {index}/{page_count} 페이지")

    writer.encrypt(
        user_password="",
        owner_password="vintage_appMaker_owner",
        permissions_flag=permissions,
    )

    with output_pdf.open("wb") as handle:
        writer.write(handle)


def convert_pdf_to_jpg(pdf_path, output_dir, quality=80, scale=2.0, progress=None):
    try:
        import fitz
        from PIL import Image
    except ImportError as exc:
        missing = getattr(exc, "name", "필수 패키지")
        dependency_error(missing, "PyMuPDF Pillow")
        raise

    output_dir.mkdir(parents=True, exist_ok=True)
    document = fitz.open(pdf_path)
    matrix = fitz.Matrix(scale, scale)
    saved_files = []

    try:
        page_count = document.page_count
        for index, page in enumerate(document, start=1):
            pixmap = page.get_pixmap(matrix=matrix, alpha=False)
            image = Image.frombytes("RGB", [pixmap.width, pixmap.height], pixmap.samples)
            output_path = output_dir / f"{index}.jpg"
            image.save(output_path, "JPEG", quality=quality, optimize=True)
            saved_files.append(output_path)
            if progress:
                progress(f"JPG 저장 중: {index}/{page_count} 페이지")
    finally:
        document.close()

    return saved_files


def start_pdf_converter():
    Console.clear()
    draw_header("PDF 변환")
    print()
    pdf_path = prompt_existing_path("변환할 PDF 파일 경로를 입력하세요", "file")

    while True:
        watermark_text = prompt_line("워터마크 문구를 입력하세요", required=True).strip()
        if watermark_text:
            break
        print(Console.color("워터마크 문구는 필수입니다.", Console.YELLOW))

    opacity = prompt_float("워터마크 투명도(0.0 ~ 1.0)", 0.2, 0.0, 1.0)
    quality = prompt_int("JPG 품질(1 ~ 100)", 80, 1, 100)
    output_dir = select_path(
        "JPG 저장 폴더를 선택하세요",
        "directory",
        start_dir=pdf_path.parent,
        default_path=pdf_path.parent / "images",
    )
    if output_dir is None:
        output_dir = pdf_path.parent / "images"
    watermarked_pdf = pdf_path.parent / f"{pdf_path.stem}_watermarked_no_print.pdf"

    try:
        import PyPDF2  # noqa: F401
        import reportlab  # noqa: F401
    except ImportError as exc:
        missing = getattr(exc, "name", "필수 패키지")
        dependency_error(missing, "PyPDF2 reportlab")
        wait_for_key()
        return

    activity = Activity("PDF 워터마크 및 JPG 변환")
    activity.log(f"입력 PDF: {pdf_path}")
    activity.start()

    try:
        apply_watermark(pdf_path, watermarked_pdf, watermark_text, opacity, activity.log)
        activity.log(f"워터마크 PDF 저장: {watermarked_pdf}")
        saved_files = convert_pdf_to_jpg(watermarked_pdf, output_dir, quality=quality, progress=activity.log)
        activity.log(f"완료: JPG {len(saved_files)}개 저장 ({output_dir})")
        time.sleep(0.5)
    except Exception as exc:
        activity.log(f"오류: {exc}")
        time.sleep(0.5)
    finally:
        activity.stop()

    print()
    wait_for_key()


def main():
    Console.configure_output_encoding()
    Console.enable_windows_ansi()
    try:
        print(Console.HIDE_CURSOR, end="")
        while True:
            choice = menu_loop()
            if choice == "1":
                start_jpg_converter()
            elif choice == "2":
                start_pdf_converter()
            elif choice == "0":
                Console.clear()
                print("종료합니다.")
                return
    finally:
        print(Console.SHOW_CURSOR + Console.RESET, end="")


if __name__ == "__main__":
    main()

 

Comments