PDF 작업을 할 때마다 별도 유료 앱을 열기가 번거로웠습니다. 여러 PDF를 하나로 합치거나, 특정 페이지만 추출하거나, 또는 업무용으로 N장씩 균등 분할해야 할 때 — 이 모든 걸 Python 하나로 처리할 수 있도록 두 가지 도구를 만들었습니다.
✅ 두 가지 도구 소개
- pdf_tool_gui.pyw — 풀기능 Merge & Split GUI: 썸네일 미리보기, 드래그앤드롭, HiDPI 지원
- onboarding_dnd.pyw — 단순 N장 단위 분할기: 상담사 배분 등 균등 분할 업무에 최적
🛠 필요 환경 (Requirements)
Python 3.8 이상이 필요합니다. 아직 설치되지 않았다면 python.org에서 받으세요.
# 필수 패키지
pip install pypdf pymupdf pillow
# (선택) 드래그앤드롭 기능 사용 시
pip install tkinterdnd2| 패키지 | 역할 |
|---|---|
pypdf | PDF 페이지 읽기 / 쓰기 / 합치기 |
pymupdf (fitz) | 썸네일 이미지 렌더링 |
pillow | 렌더링된 이미지를 tkinter에 표시 |
tkinterdnd2 | 파일 드래그앤드롭 (선택 설치) |
▶ 실행 방법 — .pyw 파일로 저장
코드를 pdf_tool_gui.pyw 이름으로 저장 후 더블클릭하면 바로 실행됩니다.
📌 .pyw 확장자란?
일반 .py 파일은 실행 시 검은 콘솔 창이 함께 뜹니다. .pyw로 저장하면 콘솔 창 없이 GUI 창만 깔끔하게 실행됩니다.
📋 사용 방법
Merge 탭 — PDF 합치기
- Add PDFs… 클릭 (또는 파일을 목록으로 드래그앤드롭)
- Move Up / Move Down으로 순서 조정
- Choose Folder…로 출력 폴더 선택
- Merge Now 클릭 →
merged_202512281430.pdf자동 저장
Split 탭 — PDF 분할
- Browse… 로 PDF 선택 → 썸네일 자동 생성
- 원하는 페이지 클릭 선택 (또는 범위 텍스트 입력:
1-3,5,7-) - 출력 옵션 선택 — 개별 파일 / 합본 / 둘 다
- Split Now 클릭
💡 코드 구조 설명 — pdf_tool_gui.pyw
1. 임포트 & 의존성 체크
DND_AVAILABLE = False
try:
import tkinterdnd2 as TkinterDnD
BaseTk = TkinterDnD.Tk
DND_AVAILABLE = True
except Exception:
BaseTk = tk.Tk # 드래그앤드롭 없이도 정상 동작
try:
from pypdf import PdfReader, PdfWriter
except Exception as e:
raise SystemExit("pypdf 가 필요합니다: pip install pypdf")
try:
import fitz # PyMuPDF
from PIL import Image, ImageTk
except Exception as e:
raise SystemExit("pip install pymupdf pillow")tkinterdnd2는 선택 설치입니다. 없으면 일반 tk.Tk로 fallback되어 드래그앤드롭만 비활성화되고 나머지 기능은 모두 동작합니다. pypdf와 pymupdf는 필수이므로 없으면 바로 안내 메시지를 띄우고 종료합니다.
2. 헬퍼 함수들
def now_ts():
return datetime.now().strftime("%Y%m%d%H%M")
def parse_ranges(ranges_text, total_pages):
# "1-3,5,7-" 형태를 페이지 인덱스 리스트로 변환
for part in ranges_text.split(","):
if "-" in part:
start, end = part.split("-", 1)
start = 1 if not start.strip() else int(start)
end = total_pages if not end.strip() else int(end)
# 범위 내 페이지 추가
else:
# 단일 페이지 추가
def parse_dnd_paths(data):
# 중괄호({...}) 그룹과 공백 구분 경로를 모두 처리
# file:// URI를 일반 경로로 정규화
# Windows 경로 앞의 슬래시 제거now_ts()는 파일명에 타임스탬프를 붙여 덮어쓰기를 방지합니다. parse_ranges()는 1-3,5,7- 같은 범위 표현을 파싱합니다 — 앞/뒤가 빈 경우(-4, 7-)도 처리합니다. parse_dnd_paths()는 OS마다 다른 드래그앤드롭 데이터 형식을 통일하는 핵심 유틸리티입니다.
3. ThumbnailGrid — 썸네일 위젯
class ThumbnailGrid(ttk.Frame):
def populate_from_pdf(self, pdf_path):
doc = fitz.open(pdf_path)
for i, page in enumerate(doc):
zoom = self.thumb_width / max(page.rect.width, 1)
pm = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
img = Image.frombytes("RGB", [pm.width, pm.height], pm.samples)
photo = ImageTk.PhotoImage(img)
# 클릭 토글 + 선택 시 파란 배경 강조
lbl.bind("<Button-1>", lambda e, idx=i: self._toggle(idx))PyMuPDF의 fitz.open()으로 PDF를 열고, 각 페이지를 get_pixmap()으로 이미지로 변환합니다. zoom 값을 조절해 썸네일 크기를 결정합니다. 클릭 시 BooleanVar를 토글하고, 선택된 타일은 Selected.TFrame 스타일(파란 배경)로 강조합니다. Select All / Select None / Invert 버튼도 지원합니다.
4. PDFToolApp — 앱 초기화 & Merge 탭
class PDFToolApp(BaseTk):
def _init_scaling(self):
sw = self.winfo_screenwidth()
if sw >= 1920: self.scale = 1.25
if sw >= 2560: self.scale = 1.4
self.tk.call('tk', 'scaling', self.scale)
def merge_pdfs(self):
out_path = os.path.join(out_dir, f"merged_{now_ts()}.pdf")
writer = PdfWriter()
for i in range(n):
for pg in PdfReader(self.listbox.get(i)).pages:
writer.add_page(pg)
with open(out_path, "wb") as f: writer.write(f)화면 해상도를 감지해 HiDPI 환경(1920px 이상)에서 자동으로 스케일을 조정합니다. Merge는 listbox의 파일을 순서대로 읽어 모든 페이지를 하나의 PdfWriter에 추가한 뒤 저장합니다. 출력 폴더를 지정하지 않으면 첫 번째 파일과 같은 폴더에 자동 저장됩니다.
5. Split 탭 — 페이지 선택 & 분할 저장
def split_pdf(self):
# 썸네일 선택이 있으면 우선, 없으면 범위 텍스트 사용
indices = self.thumb_grid.get_selected_indices()
if not indices:
indices = parse_ranges(self.ranges_var.get(), total)
# 개별 파일: part_p01_202512281430.pdf
if self.make_individual_var.get():
for p in indices:
w = PdfWriter(); w.add_page(reader.pages[p])
out_path = f"{base}_p{p+1:0{pad}d}_{ts}.pdf"
# 합본 파일: part_selection_202512281430.pdf
if self.make_combined_var.get():
writer = PdfWriter()
for p in indices: writer.add_page(reader.pages[p])썸네일 선택이 있으면 해당 페이지를 우선 사용하고, 선택이 없을 때만 범위 텍스트를 파싱합니다. 개별 파일과 합본 파일을 동시에 생성할 수 있으며, 파일명에 페이지 번호와 타임스탬프가 자동으로 붙습니다.
🗂 보너스: 단순 분할기 — onboarding_dnd.pyw
상담사 배분, 설문지 분류처럼 균등하게 N장씩 나눠야 하는 업무에 특화된 단순 버전입니다. 결과 파일은 1번 상담사.pdf, 2번 상담사.pdf… 형태로 저장됩니다.
def split_pdf_by_unit(pdf_path, unit_pages, out_dir):
reader = PdfReader(pdf_path)
total = len(reader.pages)
base = 1; idx = 1
while base <= total:
writer = PdfWriter()
end = min(base + unit_pages - 1, total)
for p in range(base-1, end):
writer.add_page(reader.pages[p])
out_path = os.path.join(out_dir, f"{idx}번 상담사.pdf")
with open(out_path, "wb") as f: writer.write(f)
idx += 1; base += unit_pages단위(장수)를 입력하면 처음부터 끝까지 자동으로 나눠줍니다. 마지막 파일은 남은 페이지 수에 맞게 자동 처리됩니다. 드래그앤드롭도 지원합니다 (tkinterdnd2 설치 시).
📄 전체 코드 — pdf_tool_gui.pyw
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PDF Merge & Split GUI (Tkinter + pypdf + PyMuPDF + Pillow)
설치:
pip install pypdf pymupdf pillow
# (선택) 드래그앤드롭:
pip install tkinterdnd2
실행:
python pdf_tool_gui.py
"""
import os
import sys
import re
import tkinter as tk
from tkinter import filedialog, messagebox
from tkinter import ttk
from datetime import datetime
# ----- Optional Drag & Drop (tkinterdnd2) -----
DND_AVAILABLE = False
try:
import tkinterdnd2 as TkinterDnD
BaseTk = TkinterDnD.Tk
DND_AVAILABLE = True
except Exception:
BaseTk = tk.Tk
try:
from pypdf import PdfReader, PdfWriter
except Exception as e:
raise SystemExit("pypdf 가 필요합니다. 설치: pip install pypdfn" + str(e))
try:
import fitz # PyMuPDF
from PIL import Image, ImageTk
except Exception as e:
raise SystemExit("미리보기용 패키지 필요: pip install pymupdf pillown" + str(e))
APP_TITLE = "PDF Merge & Split"
APP_MIN_W, APP_MIN_H = 1180, 800
def now_ts():
return datetime.now().strftime("%Y%m%d%H%M")
def human_path(path):
try:
return os.path.relpath(path, os.getcwd())
except Exception:
return path
def parse_ranges(ranges_text, total_pages):
if not ranges_text.strip():
raise ValueError("Page ranges cannot be empty.")
pages = set()
for part in [p.strip() for p in ranges_text.split(",") if p.strip()]:
if "-" in part:
start_str, end_str = part.split("-", 1)
start = 1 if start_str.strip() == "" else int(start_str)
end = total_pages if end_str.strip() == "" else int(end_str)
for p in range(start, end + 1):
if 1 <= p <= total_pages:
pages.add(p - 1)
else:
p = int(part)
if p <= total_pages:
pages.add(p - 1)
return sorted(pages)
_URI_RE = re.compile(r'^file://', re.IGNORECASE)
def _normalize_uri(p):
if _URI_RE.match(p):
from urllib.parse import unquote, urlparse
u = urlparse(p)
path = unquote(u.path)
if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
path = path.lstrip("/")
return path
return p
def parse_dnd_paths(data):
if not data: return []
out, token, brace = [], "", 0
for ch in data:
if ch == "{":
brace += 1
if brace == 1: token = ""; continue
if ch == "}":
brace -= 1
if brace == 0:
if token: out.append(token); token = ""
continue
if brace > 0: token += ch
else:
if ch in ("n", "r", "t"): ch = " "
token += ch
if token: out.extend(token.split())
cleaned = [_normalize_uri(p.strip().strip('"').strip("'")) for p in out]
more = [_normalize_uri(s) for raw in data.replace("r", "n").split("n")
for s in raw.strip().strip('"').strip("'").split()
if raw.strip() and "{" not in raw and "}" not in raw]
uniq, seen = [], set()
for p in cleaned + more:
if not p: continue
p = os.path.expanduser(os.path.expandvars(p))
if p.lower().endswith(".pdf"):
ap = os.path.abspath(p)
if ap not in seen: seen.add(ap); uniq.append(ap)
return uniq
class ThumbnailGrid(ttk.Frame):
def __init__(self, master, thumb_width=200, columns=5, **kwargs):
super().__init__(master, **kwargs)
self.thumb_width = thumb_width
self.columns = columns
self.canvas = tk.Canvas(self, borderwidth=0, highlightthickness=0)
self.vsb = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
self.inner = ttk.Frame(self.canvas)
self.inner.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.create_window((0,0), window=self.inner, anchor="nw")
self.canvas.configure(yscrollcommand=self.vsb.set)
self.canvas.pack(side="left", fill="both", expand=True)
self.vsb.pack(side="right", fill="y")
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
self.page_vars, self.photo_refs, self.tile_frames = [], [], []
def _on_mousewheel(self, event):
step = -1 * (event.delta // 120) if sys.platform != "darwin" else -1 * int(event.delta)
self.canvas.yview_scroll(step, "units")
def clear(self):
for c in self.inner.winfo_children(): c.destroy()
self.page_vars.clear(); self.photo_refs.clear(); self.tile_frames.clear()
def populate_from_pdf(self, pdf_path):
self.clear()
doc = fitz.open(pdf_path)
for i, page in enumerate(doc):
zoom = self.thumb_width / max(page.rect.width, 1)
pm = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
img = Image.frombytes("RGB", [pm.width, pm.height], pm.samples)
photo = ImageTk.PhotoImage(img); self.photo_refs.append(photo)
var = tk.BooleanVar(value=False)
tile = ttk.Frame(self.inner, padding=6, relief="groove")
r, c = divmod(i, self.columns)
tile.grid(row=r, column=c, padx=8, pady=8, sticky="nsew")
self.tile_frames.append(tile); self.page_vars.append(var)
lbl = ttk.Label(tile, image=photo, cursor="hand2"); lbl.pack()
cap = ttk.Checkbutton(tile, text=f"Page {i+1}", variable=var,
command=lambda idx=i: self._sync_style(idx))
cap.pack(anchor="w", pady=(6,2))
lbl.bind("<Button-1>", lambda e, idx=i: self._toggle(idx))
tile.bind("<Button-1>", lambda e, idx=i: self._toggle(idx))
self._sync_style(i)
for c in range(self.columns): self.inner.grid_columnconfigure(c, weight=1)
def _toggle(self, idx):
self.page_vars[idx].set(not self.page_vars[idx].get())
self._sync_style(idx)
def _sync_style(self, idx):
self.tile_frames[idx].configure(
style="Selected.TFrame" if self.page_vars[idx].get() else "TFrame")
def get_selected_indices(self):
return [i for i, v in enumerate(self.page_vars) if v.get()]
def select_all(self):
for i in range(len(self.page_vars)): self.page_vars[i].set(True); self._sync_style(i)
def select_none(self):
for i in range(len(self.page_vars)): self.page_vars[i].set(False); self._sync_style(i)
def invert(self):
for i in range(len(self.page_vars)): self.page_vars[i].set(not self.page_vars[i].get()); self._sync_style(i)
class PDFToolApp(BaseTk):
def __init__(self):
super().__init__()
self.title(APP_TITLE)
self._init_scaling()
self.minsize(int(APP_MIN_W * self.scale), int(APP_MIN_H * self.scale))
style = ttk.Style(self); style.configure("Selected.TFrame", background="#d9f0ff")
self._build_menu()
self.notebook = ttk.Notebook(self)
self.merge_tab = ttk.Frame(self.notebook)
self.split_tab = ttk.Frame(self.notebook)
self.notebook.add(self.merge_tab, text="Merge PDFs")
self.notebook.add(self.split_tab, text="Split PDF")
self.notebook.pack(fill="both", expand=True)
self._build_merge_tab()
self._build_split_tab()
if not DND_AVAILABLE:
self.after(600, self._maybe_warn_dnd)
def _init_scaling(self):
self.scale = 1.0
try:
sw = self.winfo_screenwidth()
if sw >= 1920: self.scale = 1.25
if sw >= 2560: self.scale = 1.4
self.tk.call('tk', 'scaling', self.scale)
except Exception:
pass
def _build_menu(self):
menubar = tk.Menu(self)
view = tk.Menu(menubar, tearoff=0)
view.add_command(label="Zoom In", command=lambda: self._zoom(1.1))
view.add_command(label="Zoom Out", command=lambda: self._zoom(1/1.1))
view.add_command(label="Reset Zoom", command=self._zoom_reset)
menubar.add_cascade(label="View", menu=view)
self.config(menu=menubar)
def _zoom(self, factor):
self.scale *= factor
try: self.tk.call('tk', 'scaling', self.scale)
except Exception: pass
def _zoom_reset(self):
self.scale = 1.0
try: self.tk.call('tk', 'scaling', self.scale)
except Exception: pass
def _build_merge_tab(self):
root = self.merge_tab
left = ttk.Frame(root, padding=10); left.pack(side="left", fill="both", expand=True)
ttk.Label(left, text="Files to merge (in order):").pack(anchor="w")
self.listbox = tk.Listbox(left, selectmode=tk.EXTENDED, activestyle="dotbox")
self.listbox.pack(fill="both", expand=True, pady=(4,6))
if DND_AVAILABLE:
self.listbox.drop_target_register(TkinterDnD.DND_FILES)
self.listbox.dnd_bind('<<Drop>>', self._on_merge_drop)
btns = ttk.Frame(left); btns.pack(fill="x", pady=(0,8))
ttk.Button(btns, text="Add PDFs…", command=self.add_pdfs).pack(side="left")
ttk.Button(btns, text="Remove", command=self.remove_selected).pack(side="left", padx=6)
ttk.Button(btns, text="Move Up", command=lambda: self.move_selection(-1)).pack(side="left")
ttk.Button(btns, text="Move Down", command=lambda: self.move_selection(1)).pack(side="left")
right = ttk.Frame(root, padding=10); right.pack(side="right", fill="y")
out = ttk.LabelFrame(right, text="Output folder"); out.pack(fill="x", pady=(0,8))
self.merge_out_dir_var = tk.StringVar(value="")
ttk.Entry(out, textvariable=self.merge_out_dir_var, state="readonly", width=44).grid(row=1, column=0, sticky="ew", padx=8, pady=6)
ttk.Button(out, text="Choose Folder…", command=self.choose_merge_output_folder).grid(row=2, column=0, sticky="e", padx=8, pady=(0,8))
ttk.Button(right, text="Merge Now", command=self.merge_pdfs).pack(fill="x")
def _on_merge_drop(self, event):
for p in parse_dnd_paths(event.data):
if os.path.isfile(p): self.listbox.insert(tk.END, p)
def choose_merge_output_folder(self):
d = filedialog.askdirectory(title="Choose output folder")
if d: self.merge_out_dir_var.set(d)
def add_pdfs(self):
paths = filedialog.askopenfilenames(filetypes=[("PDF files","*.pdf")])
for p in paths:
if p.lower().endswith(".pdf"): self.listbox.insert(tk.END, p)
def remove_selected(self):
for i in reversed(list(self.listbox.curselection())):
self.listbox.delete(i)
def move_selection(self, direction):
sel = list(self.listbox.curselection())
if not sel: return
if direction < 0:
for i in sel:
if i == 0: continue
text = self.listbox.get(i)
self.listbox.delete(i); self.listbox.insert(i-1, text)
self.listbox.selection_set(i-1)
else:
for i in reversed(sel):
if i == self.listbox.size()-1: continue
text = self.listbox.get(i)
self.listbox.delete(i); self.listbox.insert(i+1, text)
self.listbox.selection_set(i+1)
def merge_pdfs(self):
n = self.listbox.size()
if n == 0:
messagebox.showwarning("No files", "Please add at least one PDF."); return
out_dir = (self.merge_out_dir_var.get() or "").strip()
if not out_dir:
out_dir = os.path.dirname(self.listbox.get(0)) or "."
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f"merged_{now_ts()}.pdf")
writer = PdfWriter()
for i in range(n):
for pg in PdfReader(self.listbox.get(i)).pages:
writer.add_page(pg)
with open(out_path, "wb") as f: writer.write(f)
messagebox.showinfo("Done", f"Merged {n} file(s):n{human_path(out_path)}")
def _build_split_tab(self):
root = self.split_tab
frm = ttk.Frame(root, padding=10); frm.pack(fill="both", expand=True)
in_frame = ttk.LabelFrame(frm, text="Input PDF"); in_frame.pack(fill="x", pady=(0,10))
self.split_in_var = tk.StringVar(value="")
self.split_in_entry = ttk.Entry(in_frame, textvariable=self.split_in_var)
self.split_in_entry.pack(side="left", padx=8, pady=8, fill="x", expand=True)
ttk.Button(in_frame, text="Browse…", command=self.browse_split_input).pack(side="left", padx=(0,8), pady=8)
if DND_AVAILABLE:
for w in (self.split_in_entry, in_frame):
w.drop_target_register(TkinterDnD.DND_FILES)
w.dnd_bind('<<Drop>>', self._on_split_drop)
sel_frame = ttk.LabelFrame(frm, text="Preview & Select Pages"); sel_frame.pack(fill="both", expand=True, pady=(0,10))
ctrl = ttk.Frame(sel_frame); ctrl.pack(fill="x", padx=8, pady=(8,4))
ttk.Button(ctrl, text="Select All", command=lambda: self.thumb_grid.select_all()).pack(side="left")
ttk.Button(ctrl, text="Select None", command=lambda: self.thumb_grid.select_none()).pack(side="left", padx=6)
ttk.Button(ctrl, text="Invert", command=lambda: self.thumb_grid.invert()).pack(side="left")
self.thumb_grid = ThumbnailGrid(sel_frame, thumb_width=200, columns=5)
self.thumb_grid.pack(fill="both", expand=True, padx=8, pady=(0,8))
rng_frame = ttk.LabelFrame(frm, text="Page Ranges (e.g. 1-3,5,7-)"); rng_frame.pack(fill="x", pady=(0,10))
self.ranges_var = tk.StringVar(value="")
ttk.Entry(rng_frame, textvariable=self.ranges_var).pack(fill="x", padx=8, pady=8)
opt = ttk.LabelFrame(frm, text="Output options"); opt.pack(fill="x", pady=(0,10))
self.make_individual_var = tk.BooleanVar(value=True)
self.make_combined_var = tk.BooleanVar(value=True)
ttk.Checkbutton(opt, text="개별 파일 (페이지마다 1개)", variable=self.make_individual_var).pack(anchor="w", padx=8, pady=(6,2))
ttk.Checkbutton(opt, text="합본 파일 (선택 전체 1개)", variable=self.make_combined_var).pack(anchor="w", padx=8, pady=(2,8))
out = ttk.LabelFrame(frm, text="Output folder"); out.pack(fill="x", pady=(0,10))
self.split_out_dir_var = tk.StringVar(value="")
ttk.Entry(out, textvariable=self.split_out_dir_var, state="readonly").grid(row=0, column=1, sticky="ew", padx=8, pady=(8,2))
ttk.Button(out, text="Choose…", command=self.choose_output_dir).grid(row=0, column=2, padx=8, pady=(8,2))
self.base_name_var = tk.StringVar(value="part")
ttk.Entry(out, textvariable=self.base_name_var).grid(row=1, column=1, sticky="ew", padx=8, pady=(2,8))
out.columnconfigure(1, weight=1)
ttk.Button(frm, text="Split Now", command=self.split_pdf).pack(anchor="e")
def _on_split_drop(self, event):
paths = parse_dnd_paths(event.data)
if not paths: return
p = paths[0]
if os.path.isfile(p):
self.split_in_var.set(p)
self.thumb_grid.populate_from_pdf(p)
def browse_split_input(self):
path = filedialog.askopenfilename(filetypes=[("PDF files","*.pdf")])
if path:
self.split_in_var.set(path)
self.thumb_grid.populate_from_pdf(path)
def choose_output_dir(self):
path = filedialog.askdirectory()
if path: self.split_out_dir_var.set(path)
def split_pdf(self):
src = self.split_in_var.get().strip()
if not src or not os.path.isfile(src):
messagebox.showwarning("No input", "유효한 PDF 파일을 선택해주세요."); return
reader = PdfReader(src)
total = len(reader.pages)
indices = self.thumb_grid.get_selected_indices()
if not indices:
ranges_text = self.ranges_var.get().strip()
if not ranges_text:
messagebox.showwarning("No pages", "썸네일에서 페이지를 선택하거나 범위를 입력해주세요."); return
indices = parse_ranges(ranges_text, total)
out_dir = self.split_out_dir_var.get().strip() or os.path.dirname(src) or "."
os.makedirs(out_dir, exist_ok=True)
base = self.base_name_var.get().strip() or "part"
ts = now_ts()
outputs = []
pad = max(2, len(str(total)))
if self.make_individual_var.get():
for p in indices:
w = PdfWriter(); w.add_page(reader.pages[p])
out_path = os.path.join(out_dir, f"{base}_p{p+1:0{pad}d}_{ts}.pdf")
with open(out_path, "wb") as f: w.write(f)
outputs.append(out_path)
if self.make_combined_var.get():
writer = PdfWriter()
for p in indices: writer.add_page(reader.pages[p])
out_path = os.path.join(out_dir, f"{base}_selection_{ts}.pdf")
with open(out_path, "wb") as f: writer.write(f)
outputs.append(out_path)
messagebox.showinfo("Done", "생성된 파일:n" + "n".join(human_path(p) for p in outputs))
def _maybe_warn_dnd(self):
if not DND_AVAILABLE:
messagebox.showinfo("Drag & Drop",
"드래그앤드롭을 사용하려면:nn pip install tkinterdnd2nn설치 없이도 정상 동작합니다.")
def main():
app = PDFToolApp()
try:
if sys.platform.startswith("win"):
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
app.mainloop()
if __name__ == "__main__":
main()📄 전체 코드 — onboarding_dnd.pyw
# onboarding_dnd.py
# 요구: pip install pypdf tkinterdnd2
import os
import re
from tkinter import Tk, Label, Entry, Button, filedialog, messagebox, StringVar
from urllib.parse import unquote, urlparse
from pypdf import PdfReader, PdfWriter
DND_AVAILABLE = False
try:
import tkinterdnd2 as TkinterDnD
BaseTk = TkinterDnD.Tk
DND_AVAILABLE = True
except Exception:
BaseTk = Tk
_URI_RE = re.compile(r'^file://', re.IGNORECASE)
def _normalize_uri(p):
if _URI_RE.match(p):
u = urlparse(p)
path = unquote(u.path)
if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
path = path.lstrip("/")
return path
return p
def parse_dnd_paths(data):
if not data: return []
out, token, brace = [], "", 0
for ch in data:
if ch == "{":
brace += 1
if brace == 1: token = ""; continue
if ch == "}":
brace -= 1
if brace == 0:
if token: out.append(token); token = ""
continue
if brace > 0: token += ch
else:
if ch in ("n", "r", "t"): ch = " "
token += ch
if token: out.extend(token.split())
cleaned = [_normalize_uri(p.strip().strip('"').strip("'")) for p in out]
more = [_normalize_uri(s) for raw in data.replace("r", "n").split("n")
for s in raw.strip().strip('"').strip("'").split()
if raw.strip() and "{" not in raw and "}" not in raw]
uniq, seen = [], set()
for p in cleaned + more:
if not p: continue
p = os.path.expanduser(os.path.expandvars(p))
if p.lower().endswith(".pdf"):
ap = os.path.abspath(p)
if ap not in seen: seen.add(ap); uniq.append(ap)
return uniq
def split_pdf_by_unit(pdf_path, unit_pages, out_dir):
reader = PdfReader(pdf_path)
total = len(reader.pages)
base = 1; idx = 1
while base <= total:
writer = PdfWriter()
end = min(base + unit_pages - 1, total)
for p in range(base-1, end):
writer.add_page(reader.pages[p])
out_path = os.path.join(out_dir, f"{idx}번 상담사.pdf")
with open(out_path, "wb") as f: writer.write(f)
idx += 1; base += unit_pages
def browse_pdf():
path = filedialog.askopenfilename(filetypes=[("PDF files", "*.pdf")])
if path:
pdf_var.set(path)
if not outdir_var.get().strip():
outdir_var.set(os.path.dirname(path))
def browse_outdir():
path = filedialog.askdirectory()
if path: outdir_var.set(path)
def run_split():
if not pdf_var.get():
messagebox.showwarning("알림", "PDF 파일을 선택하세요."); return
try:
unit = int(unit_var.get() or "12")
except ValueError:
messagebox.showerror("오류", "단위(장수)는 숫자여야 합니다."); return
outdir = (outdir_var.get() or os.path.dirname(pdf_var.get())).strip()
split_pdf_by_unit(pdf_var.get(), unit, outdir)
messagebox.showinfo("완료", f"분할이 완료되었습니다.n저장 위치: {outdir}")
def _on_drop(event):
paths = parse_dnd_paths(event.data)
if not paths: return
p = paths[0]
if os.path.isfile(p):
pdf_var.set(p)
if not outdir_var.get().strip():
outdir_var.set(os.path.dirname(p))
root = BaseTk()
root.title("PDF 분할 (단위 선택)")
pdf_var = StringVar(); outdir_var = StringVar(); unit_var = StringVar(value="12")
Label(root, text="PDF 파일:").grid(row=0, column=0, sticky="w", padx=6, pady=6)
pdf_entry = Entry(root, textvariable=pdf_var, width=50)
pdf_entry.grid(row=0, column=1, padx=6, pady=6)
Button(root, text="찾기", command=browse_pdf).grid(row=0, column=2, padx=6, pady=6)
Label(root, text="저장 폴더:").grid(row=1, column=0, sticky="w", padx=6, pady=6)
Entry(root, textvariable=outdir_var, width=50).grid(row=1, column=1, padx=6, pady=6)
Button(root, text="선택", command=browse_outdir).grid(row=1, column=2, padx=6, pady=6)
Label(root, text="단위(장):").grid(row=2, column=0, sticky="w", padx=6, pady=6)
Entry(root, textvariable=unit_var, width=10).grid(row=2, column=1, sticky="w", padx=6, pady=6)
Button(root, text="분할 실행", command=run_split).grid(row=3, column=1, pady=12)
if DND_AVAILABLE:
pdf_entry.drop_target_register(TkinterDnD.DND_FILES)
pdf_entry.dnd_bind('<<Drop>>', _on_drop)
root.mainloop()📌 주의사항: 두 도구 모두 Windows에서 테스트되었습니다. macOS/Linux에서는 os.startfile()이 없으므로 해당 부분을 제거하거나 subprocess.Popen(["open", path]) 등으로 대체하세요.