""" 文章处理工具 v2 单篇处理 + 批量处理(选文件夹 → 处理所有 txt → 替换源文件 / 另存) GUI: CustomTkinter """ import re import random import threading import os from pathlib import Path from tkinter import filedialog import customtkinter as ctk # ══════════════════════════════════════════════════════════════ # 示例文本 # ══════════════════════════════════════════════════════════════ EXAMPLE_TEXT = ( '他说:"时间会证明一切"。*这是一个星号标记*,还有下划线_test_。' '实际上,这段话里包含引号、星号、下划线等复杂符号。' '我们的工具应该能自动删除这些符号,只保留常规标点,比如逗号,句号。' '感叹号!问号?冒号:以及书名号《西游记》。' '长句子超过十五个字符的时候,我们会在逗号处强制拆分成短句。' '另外,段落会随机分成2-4句一段,并且相邻段落的句子数量不会相同。' '每个段落内部,除了最后一个句子末尾是句号,其他句子末尾都会变成逗号。' '这是一个特别长的句子,它里面有很多内容,比如我们故意写很多字,' '让它超过十五个字符,并且中间有逗号,那么它就会被拆开。' ) # ══════════════════════════════════════════════════════════════ # 核心处理逻辑 # ══════════════════════════════════════════════════════════════ def convert_quotes_to_corner(text: str) -> str: """全角/半角双引号 → 直角引号「」""" text = text.replace('\u201c', '「').replace('\u201d', '」') text = text.replace('\uff02', '"') out, left = [], True for ch in text: if ch == '"': out.append('「' if left else '」') left = not left else: out.append(ch) return ''.join(out) def clean_symbols(text: str, keep_corner: bool = False) -> str: """过滤复杂符号,只保留汉字/数字/字母/常规标点""" pat = (r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:「」]' if keep_corner else r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:]') return re.sub(pat, '', text) def split_long_sentence(sentence: str) -> list: """长句强制递归拆分(支持中英文逗号)""" s = sentence.strip() if len(s) <= 15: return [s] comma_pos = next( ( i for i, ch in enumerate(s) if ch in ",," and 2 < i < len(s) - 2 ), -1 ) if comma_pos == -1: return [s] chars = list(s) chars[comma_pos] = '。' new_text = ''.join(chars) parts = new_text.split('。') if len(parts) < 2: return [s] first = parts[0] + '。' rest = '。'.join(parts[1:]) second = rest if new_text.endswith('。') else rest + '。' return split_long_sentence(first) + split_long_sentence(second) def apply_split(text: str) -> str: raws = re.findall(r'[^。!?!?]+[。!?!?]', text) if not raws: return text result = [] for sent in raws: result.extend(split_long_sentence(sent)) return ''.join(result) def get_sentences(text: str) -> list: matches = re.findall(r'[^。!?!?]+[。!?!?]', text) return [s.strip() for s in matches if s.strip()] def group_paragraphs(sentences: list, rnd=None) -> list: """每段 2~4 句,相邻段句数不同""" if not sentences: return [] paras, i, total = [], 0, len(sentences) while i < total: if rnd is None: rnd = random size = random.randint(2, 4) if total - i == 1 and paras: paras[-1] += sentences[i] break end = min(i + size, total) if total - end == 1 and end < total: end -= 1 if end <= i: end = i + 1 paras.append(''.join(sentences[i:end])) i = end # 保证相邻段句数不同 idx = 1 while idx < len(paras): pc = len(get_sentences(paras[idx - 1])) cc = len(get_sentences(paras[idx])) if pc == cc: cs = get_sentences(paras[idx]) if len(cs) > 2: paras[idx - 1] += cs.pop(0) paras[idx] = ''.join(cs) elif len(get_sentences(paras[idx - 1])) > 2: ps = get_sentences(paras[idx - 1]) last = ps.pop() paras[idx - 1] = ''.join(ps) paras[idx] = last + paras[idx] else: paras[idx - 1] += paras.pop(idx) idx -= 1 idx += 1 # 保证每段至少 2 句 idx2 = 0 while idx2 < len(paras): if len(get_sentences(paras[idx2])) == 1 and len(paras) > 1: if idx2 > 0: paras[idx2 - 1] += paras.pop(idx2) idx2 -= 1 elif idx2 + 1 < len(paras): paras[idx2 + 1] = paras.pop(idx2) + paras[idx2] idx2 -= 1 idx2 += 1 return paras def unify_punctuation(paragraph: str) -> str: """末句句号,其余句子改逗号""" sents = re.findall(r'[^。!?!?]+[。!?!?]', paragraph) if not sents: return paragraph sents = [s.strip() for s in sents] result = [] for i, sent in enumerate(sents): body = sent for j in range(len(sent) - 1, -1, -1): if sent[j] in '。!?': body = sent[:j] break result.append(body + ('。' if i == len(sents) - 1 else ',')) return ''.join(result) def transform(text: str, mode: str = 'remove') -> str: """ mode='remove' → 去AI排版 mode='corner' → 转直角引号 """ if not text or not text.strip(): return '' try: # 统一预处理 text = normalize_text(text) if mode == 'corner': text = convert_quotes_to_corner(text) cleaned = clean_symbols(text, keep_corner=True) else: cleaned = clean_symbols(text, keep_corner=False) split_text = apply_split(cleaned) sentences = get_sentences(split_text) if not sentences: return split_text # 稳定随机(同一文件结果固定) rnd = random.Random(hash(text)) paragraphs = group_paragraphs(sentences, rnd) return '\n\n'.join( unify_punctuation(p) for p in paragraphs ) except Exception as e: return f'处理出错:{e}' # ══════════════════════════════════════════════════════════════ # 配色 # ══════════════════════════════════════════════════════════════ C_GREEN = "#1f6e43" C_GREEN_HOVER = "#155a38" C_BLUE = "#2c6e8f" C_BLUE_HOVER = "#1f5c7a" C_SLATE = "#4a6f88" C_SLATE_HOVER = "#375a70" C_RED = "#a93226" C_RED_HOVER = "#7b241c" C_ORANGE = "#b7670a" ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") # ══════════════════════════════════════════════════════════════ # Toast 弹窗 # ══════════════════════════════════════════════════════════════ class Toast(ctk.CTkToplevel): def __init__(self, parent, msg: str, duration: int = 1600): super().__init__(parent) self.title("") self.geometry("320x72") self.resizable(False, False) self.grab_set() px = parent.winfo_rootx() + parent.winfo_width() // 2 - 160 py = parent.winfo_rooty() + parent.winfo_height() // 2 - 36 self.geometry(f"+{px}+{py}") ctk.CTkLabel(self, text=msg, font=ctk.CTkFont(size=13), wraplength=280).pack(expand=True, padx=16) self.after(duration, self.destroy) # ══════════════════════════════════════════════════════════════ # 单篇处理 Tab # ══════════════════════════════════════════════════════════════ class SingleTab(ctk.CTkFrame): def __init__(self, master, get_mode_fn, **kw): super().__init__(master, fg_color="transparent", **kw) self._get_mode = get_mode_fn self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=1) self._build() def _build(self): # ── 左列 ────────────────────────────────────────────── left = ctk.CTkFrame(self, corner_radius=18) left.grid(row=0, column=0, sticky="nsew", padx=(0, 10), pady=0) left.grid_rowconfigure(1, weight=1) left.grid_columnconfigure(0, weight=1) lh = ctk.CTkFrame(left, fg_color="transparent") lh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0)) ctk.CTkLabel(lh, text="📄 原始文章", font=ctk.CTkFont(size=14, weight="bold")).pack(side="left") for label, cmd, color, hov in [ ("加载示例", self._load_example, C_BLUE, C_BLUE_HOVER), ("清 空", self._clear_left, C_SLATE, C_SLATE_HOVER), ("复制原文", self._copy_source, C_SLATE, C_SLATE_HOVER), ]: ctk.CTkButton(lh, text=label, width=72, height=30, corner_radius=20, fg_color=color, hover_color=hov, font=ctk.CTkFont(size=12), command=cmd).pack(side="right", padx=3) self._src = ctk.CTkTextbox(left, font=ctk.CTkFont(size=14), wrap="word", corner_radius=12) self._src.grid(row=1, column=0, sticky="nsew", padx=12, pady=10) # ── 右列 ────────────────────────────────────────────── right = ctk.CTkFrame(self, corner_radius=18) right.grid(row=0, column=1, sticky="nsew", padx=(10, 0), pady=0) right.grid_rowconfigure(1, weight=1) right.grid_columnconfigure(0, weight=1) rh = ctk.CTkFrame(right, fg_color="transparent") rh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0)) ctk.CTkLabel(rh, text="✨ 调整后", font=ctk.CTkFont(size=14, weight="bold")).pack(side="left") ctk.CTkButton(rh, text="复制结果", width=72, height=30, corner_radius=20, fg_color=C_SLATE, hover_color=C_SLATE_HOVER, font=ctk.CTkFont(size=12), command=self._copy_result).pack(side="right", padx=3) self._res = ctk.CTkTextbox(right, font=ctk.CTkFont(size=14), wrap="word", corner_radius=12) self._res.grid(row=1, column=0, sticky="nsew", padx=12, pady=10) self._set_result("等待调整… 请粘贴文章或点击「加载示例」后点击「一键调整」") # ── 底部按钮 ─────────────────────────────────────────── bar = ctk.CTkFrame(self, fg_color="transparent") bar.grid(row=1, column=0, columnspan=2, pady=(8, 4)) ctk.CTkButton(bar, text="✨ 一键调整", width=180, height=46, corner_radius=30, font=ctk.CTkFont(size=15, weight="bold"), fg_color=C_GREEN, hover_color=C_GREEN_HOVER, command=self._do_transform).pack(side="left", padx=12) ctk.CTkButton(bar, text="🔄 同步并调整", width=180, height=46, corner_radius=30, font=ctk.CTkFont(size=15), fg_color=C_SLATE, hover_color=C_SLATE_HOVER, command=self._do_transform).pack(side="left", padx=12) # ── helpers ────────────────────────────────────────────── def _set_result(self, text: str): self._res.configure(state="normal") self._res.delete("1.0", "end") self._res.insert("1.0", text) def _toast(self, msg): Toast(self.winfo_toplevel(), msg) def _load_example(self): self._src.delete("1.0", "end") self._src.insert("1.0", EXAMPLE_TEXT) self._set_result("示例已加载,点击「一键调整」查看效果。") def _clear_left(self): self._src.delete("1.0", "end") self._set_result("左侧已清空,请粘贴新文章或加载示例。") def _copy_source(self): content = self._src.get("1.0", "end-1c") if not content.strip(): self._toast("原文为空,无可复制内容。") return root = self.winfo_toplevel() root.clipboard_clear(); root.clipboard_append(content) self._toast("✅ 原文已复制") def _copy_result(self): content = self._res.get("1.0", "end-1c") skip = ("等待调整", "左侧无内容", "左侧已清空", "示例已加载", "⚠️") if not content.strip() or any(k in content for k in skip): self._toast("没有可复制的结果,请先生成。") return root = self.winfo_toplevel() root.clipboard_clear(); root.clipboard_append(content) self._toast("✅ 结果已复制") def _do_transform(self): src = self._src.get("1.0", "end-1c") if not src.strip(): self._set_result("⚠️ 左侧无内容,请粘贴文章或点击「加载示例」。") return self._set_result(transform(src, self._get_mode())) # ══════════════════════════════════════════════════════════════ # 批量处理 Tab # ══════════════════════════════════════════════════════════════ class BatchTab(ctk.CTkFrame): def __init__(self, master, get_mode_fn, **kw): super().__init__(master, fg_color="transparent", **kw) self._get_mode = get_mode_fn self._src_dir = ctk.StringVar() self._dst_dir = ctk.StringVar() self._export_var = ctk.StringVar(value="overwrite") self._file_vars : list[tuple[Path, ctk.BooleanVar]] = [] self._cancel_evt = threading.Event() self._running = False # 左列撑满,右列固定宽度 self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, minsize=340, weight=0) self._build() # ── 构建界面(左右双栏)──────────────────────────────────── def _build(self): # ════════════════ 左栏:文件夹 + 文件列表 ════════════════ left = ctk.CTkFrame(self, corner_radius=16) left.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) left.grid_rowconfigure(2, weight=1) left.grid_columnconfigure(0, weight=1) # 源文件夹行 src_row = ctk.CTkFrame(left, fg_color="transparent") src_row.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 6)) src_row.grid_columnconfigure(1, weight=1) ctk.CTkLabel(src_row, text="源文件夹", font=ctk.CTkFont(size=12, weight="bold"), width=64).grid(row=0, column=0, padx=(0, 6)) self._src_entry = ctk.CTkEntry(src_row, textvariable=self._src_dir, font=ctk.CTkFont(size=12), placeholder_text="选择含 txt 文件的文件夹") self._src_entry.grid(row=0, column=1, sticky="ew", padx=(0, 6)) ctk.CTkButton(src_row, text="选择", width=54, height=30, corner_radius=16, fg_color=C_BLUE, hover_color=C_BLUE_HOVER, font=ctk.CTkFont(size=12), command=self._pick_src).grid(row=0, column=2, padx=(0, 4)) ctk.CTkButton(src_row, text="扫描", width=54, height=30, corner_radius=16, fg_color=C_SLATE, hover_color=C_SLATE_HOVER, font=ctk.CTkFont(size=12), command=self._scan).grid(row=0, column=3) # 列表栏头 lh = ctk.CTkFrame(left, fg_color="transparent") lh.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 4)) self._file_count_label = ctk.CTkLabel( lh, text="📂 尚未扫描", font=ctk.CTkFont(size=12), anchor="w") self._file_count_label.pack(side="left") for label, cmd in [("全选", self._check_all), ("取消全选", self._uncheck_all)]: ctk.CTkButton(lh, text=label, width=62, height=26, corner_radius=14, fg_color=C_SLATE, hover_color=C_SLATE_HOVER, font=ctk.CTkFont(size=11), command=cmd).pack(side="right", padx=2) # 可滚动 checkbox 区(撑满剩余高度) self._scroll = ctk.CTkScrollableFrame(left, corner_radius=10) self._scroll.grid(row=2, column=0, sticky="nsew", padx=10, pady=(0, 10)) self._scroll.grid_columnconfigure(0, weight=1) self._scroll.grid_columnconfigure(1, weight=1) self._placeholder = ctk.CTkLabel( self._scroll, text="扫描后文件列表将显示在此处", text_color=("gray50", "gray60"), font=ctk.CTkFont(size=12)) self._placeholder.grid(row=0, column=0, columnspan=2, pady=30) # ════════════════ 右栏:导出 + 进度 + 按钮 ════════════════ right = ctk.CTkFrame(self, corner_radius=16) right.grid(row=0, column=1, sticky="nsew", padx=(8, 0)) right.grid_rowconfigure(2, weight=1) # 日志区撑满 right.grid_columnconfigure(0, weight=1) # ── 导出设置 ── exp_card = ctk.CTkFrame(right, corner_radius=12, fg_color=("gray90", "gray20")) exp_card.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 8)) exp_card.grid_columnconfigure(1, weight=1) ctk.CTkLabel(exp_card, text="导出方式", font=ctk.CTkFont(size=12, weight="bold") ).grid(row=0, column=0, columnspan=3, padx=12, pady=(10, 6), sticky="w") self._radio_overwrite = ctk.CTkRadioButton( exp_card, text="覆盖源文件", variable=self._export_var, value="overwrite", command=self._on_export_change, font=ctk.CTkFont(size=12)) self._radio_overwrite.grid(row=1, column=0, padx=(12, 0), pady=(0, 8), sticky="w") self._radio_saveto = ctk.CTkRadioButton( exp_card, text="另存到文件夹", variable=self._export_var, value="saveto", command=self._on_export_change, font=ctk.CTkFont(size=12)) self._radio_saveto.grid(row=1, column=1, padx=16, pady=(0, 8), sticky="w") # 目标文件夹行(动态显示) self._dst_row = ctk.CTkFrame(exp_card, fg_color="transparent") self._dst_row.grid(row=2, column=0, columnspan=3, sticky="ew", padx=8, pady=(0, 10)) self._dst_row.grid_columnconfigure(0, weight=1) self._dst_entry = ctk.CTkEntry( self._dst_row, textvariable=self._dst_dir, font=ctk.CTkFont(size=12), placeholder_text="选择结果保存位置") self._dst_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6)) ctk.CTkButton(self._dst_row, text="选择", width=54, height=30, corner_radius=16, fg_color=C_BLUE, hover_color=C_BLUE_HOVER, font=ctk.CTkFont(size=12), command=self._pick_dst).grid(row=0, column=1) self._dst_row.grid_remove() # 默认隐藏 # ── 进度条 ── prog_card = ctk.CTkFrame(right, corner_radius=12, fg_color=("gray90", "gray20")) prog_card.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 8)) prog_card.grid_columnconfigure(0, weight=1) ph = ctk.CTkFrame(prog_card, fg_color="transparent") ph.grid(row=0, column=0, sticky="ew", padx=12, pady=(10, 4)) ctk.CTkLabel(ph, text="处理进度", font=ctk.CTkFont(size=12, weight="bold")).pack(side="left") self._prog_label = ctk.CTkLabel(ph, text="", font=ctk.CTkFont(size=11), text_color=("gray40", "gray70")) self._prog_label.pack(side="left", padx=8) self._prog_bar = ctk.CTkProgressBar(prog_card, height=10, corner_radius=5) self._prog_bar.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10)) self._prog_bar.set(0) # ── 日志(撑满剩余空间)── log_card = ctk.CTkFrame(right, corner_radius=12, fg_color=("gray90", "gray20")) log_card.grid(row=2, column=0, sticky="nsew", padx=12, pady=(0, 8)) log_card.grid_rowconfigure(1, weight=1) log_card.grid_columnconfigure(0, weight=1) ctk.CTkLabel(log_card, text="处理日志", font=ctk.CTkFont(size=12, weight="bold"), anchor="w").grid(row=0, column=0, sticky="w", padx=12, pady=(8, 4)) self._log = ctk.CTkTextbox(log_card, font=ctk.CTkFont(size=11, family="Courier"), wrap="none", corner_radius=8, state="disabled") self._log.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8)) # ── 按钮(固定在底部)── btn_bar = ctk.CTkFrame(right, fg_color="transparent") btn_bar.grid(row=3, column=0, sticky="ew", padx=12, pady=(0, 12)) btn_bar.grid_columnconfigure(0, weight=1) btn_bar.grid_columnconfigure(1, weight=1) self._start_btn = ctk.CTkButton( btn_bar, text="▶ 开始处理", height=42, corner_radius=24, font=ctk.CTkFont(size=14, weight="bold"), fg_color=C_GREEN, hover_color=C_GREEN_HOVER, command=self._start_batch) self._start_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5)) self._cancel_btn = ctk.CTkButton( btn_bar, text="✖ 取消", height=42, corner_radius=24, font=ctk.CTkFont(size=13), fg_color=C_RED, hover_color=C_RED_HOVER, state="disabled", command=self._cancel_batch) self._cancel_btn.grid(row=0, column=1, sticky="ew", padx=(5, 0)) # ── 文件夹选择 ──────────────────────────────────────────── def _pick_src(self): d = filedialog.askdirectory(title="选择包含 txt 文件的文件夹") if d: self._src_dir.set(d) self._scan() def _pick_dst(self): d = filedialog.askdirectory(title="选择结果保存位置") if d: self._dst_dir.set(d) # ── 扫描文件夹 ──────────────────────────────────────────── def _scan(self): src = self._src_dir.get().strip() if not src or not os.path.isdir(src): Toast(self.winfo_toplevel(), "⚠️ 请先选择有效的源文件夹") return files = sorted(Path(src).glob("*.txt")) # 清空列表 for w in self._scroll.winfo_children(): w.destroy() self._file_vars.clear() if not files: self._file_count_label.configure(text="📂 未找到 txt 文件") ctk.CTkLabel(self._scroll, text="该文件夹内没有 .txt 文件", text_color=("gray50", "gray60"), font=ctk.CTkFont(size=12)).grid(row=0, column=0, pady=20) return self._file_count_label.configure( text=f"📂 找到 {len(files)} 个 txt 文件(可勾选/取消)") for idx, fp in enumerate(files): var = ctk.BooleanVar(value=True) self._file_vars.append((fp, var)) col = idx % 2 row = idx // 2 cb = ctk.CTkCheckBox(self._scroll, text=fp.name, variable=var, font=ctk.CTkFont(size=12)) cb.grid(row=row, column=col, sticky="w", padx=12, pady=3) self._log_clear() self._prog_bar.set(0) self._prog_label.configure(text="") # ── 全选 / 取消全选 ─────────────────────────────────────── def _check_all(self): for _, var in self._file_vars: var.set(True) def _uncheck_all(self): for _, var in self._file_vars: var.set(False) # ── 导出方式切换 ────────────────────────────────────────── def _on_export_change(self): if self._export_var.get() == "saveto": self._dst_row.grid() else: self._dst_row.grid_remove() # ── 日志操作 ────────────────────────────────────────────── def _log_clear(self): self._log.configure(state="normal") self._log.delete("1.0", "end") self._log.configure(state="disabled") def _log_append(self, line: str): """线程安全地向日志追加一行""" def _do(): self._log.configure(state="normal") self._log.insert("end", line + "\n") self._log.see("end") self._log.configure(state="disabled") self.after(0, _do) # ── 批量处理 ────────────────────────────────────────────── def _start_batch(self): checked = [(fp, var) for fp, var in self._file_vars if var.get()] if not checked: Toast(self.winfo_toplevel(), "⚠️ 请先扫描文件夹并至少勾选一个文件") return export = self._export_var.get() dst_dir = self._dst_dir.get().strip() if export == "saveto": if not dst_dir: Toast(self.winfo_toplevel(), "⚠️ 请先选择结果保存的目标文件夹") return if not os.path.isdir(dst_dir): try: os.makedirs(dst_dir, exist_ok=True) except Exception as e: Toast(self.winfo_toplevel(), f"⚠️ 无法创建目标文件夹:{e}") return mode = self._get_mode() # 锁定 UI self._running = True self._cancel_evt.clear() self._start_btn.configure(state="disabled") self._cancel_btn.configure(state="normal") self._log_clear() self._prog_bar.set(0) self._prog_label.configure(text=f"0 / {len(checked)}") threading.Thread( target=self._worker, args=(checked, export, dst_dir, mode), daemon=True ).start() def _worker(self, checked: list, export: str, dst_dir: str, mode: str): total = len(checked) success = 0 fail = 0 for idx, (fp, _) in enumerate(checked): if self._cancel_evt.is_set(): self._log_append(f"⛔ 已取消(已处理 {idx}/{total})") break try: raw = fp.read_text(encoding="utf-8", errors="replace") result = transform(raw, mode) if export == "overwrite": fp.write_text(result, encoding="utf-8") out_path = fp else: out_path = Path(dst_dir) / fp.name out_path.write_text(result, encoding="utf-8") success += 1 self._log_append(f"✅ {fp.name} → {out_path}") except Exception as e: fail += 1 self._log_append(f"❌ {fp.name} 错误:{e}") # 更新进度(回主线程) done = idx + 1 pct = done / total def _update(d=done, p=pct): self._prog_bar.set(p) self._prog_label.configure( text=f"{d} / {total} ({int(p*100)}%)") self.after(0, _update) # 完成 def _finish(): self._running = False self._start_btn.configure(state="normal") self._cancel_btn.configure(state="disabled") if not self._cancel_evt.is_set(): self._prog_bar.set(1) self._prog_label.configure(text=f"完成 ✅ 成功 {success},失败 {fail}") self._log_append( f"\n── 全部处理完毕:{success} 成功,{fail} 失败 ──") Toast(self.winfo_toplevel(), f"✅ 批量处理完成\n成功 {success} 个,失败 {fail} 个", duration=2500) self.after(0, _finish) def _cancel_batch(self): self._cancel_evt.set() self._cancel_btn.configure(state="disabled") # ======================= # 预处理函数 # ======================= def normalize_text(text: str) -> str: """统一清洗 txt 内容""" if not text: return "" # 去 BOM text = text.replace("\ufeff", "") # 统一换行 text = text.replace("\r\n", "\n").replace("\r", "\n") # 制表符转空格 text = text.replace("\t", " ") # 特殊空格 text = text.replace("\u3000", " ") # 合并多余空格 text = re.sub(r"[ ]{2,}", " ", text) # 合并多余空行 text = re.sub(r"\n{3,}", "\n\n", text) text = text.strip() # 最后一句没标点时自动补句号 if text and text[-1] not in "。!?!?": text += "。" return text # ══════════════════════════════════════════════════════════════ # 主窗口 # ══════════════════════════════════════════════════════════════ class App(ctk.CTk): def __init__(self): super().__init__() self.title("✍️ 文章处理工具") self.geometry("1300x840") self.minsize(940, 680) self._build() def _build(self): self.grid_rowconfigure(1, weight=1) self.grid_columnconfigure(0, weight=1) # ── 顶部 Hero ────────────────────────────────────────── hero = ctk.CTkFrame(self, corner_radius=18) hero.grid(row=0, column=0, sticky="ew", padx=20, pady=(16, 8)) hero.grid_columnconfigure(1, weight=1) ctk.CTkLabel(hero, text="✍️ 文章处理工具", font=ctk.CTkFont(size=22, weight="bold") ).grid(row=0, column=0, padx=20, pady=(14, 4), sticky="w") ctk.CTkLabel(hero, text="过滤符号 · 长句拆分 · 段落分组(2~4句)· 末句句号,其余逗号 | 支持单篇 & 批量处理", font=ctk.CTkFont(size=12), text_color=("gray40", "gray70"), ).grid(row=1, column=0, columnspan=2, padx=24, pady=(0, 14), sticky="w") # 全局模式切换(单篇 + 批量共用) self._mode_btn = ctk.CTkSegmentedButton( hero, values=["🧹 去AI排版", "「」 直角引号版"], font=ctk.CTkFont(size=13), width=290, ) self._mode_btn.set("🧹 去AI排版") self._mode_btn.grid(row=0, column=2, padx=20, pady=14, sticky="e") # ── Tab 视图 ─────────────────────────────────────────── self._tabs = ctk.CTkTabview(self, corner_radius=18) self._tabs.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 16)) self._tabs.grid_columnconfigure(0, weight=1) self._tabs.grid_rowconfigure(0, weight=1) self._tabs.add("📝 单篇处理") self._tabs.add("📦 批量处理") # 单篇 Tab single_host = self._tabs.tab("📝 单篇处理") single_host.grid_rowconfigure(0, weight=1) single_host.grid_columnconfigure(0, weight=1) self._single = SingleTab(single_host, self._get_mode) self._single.grid(row=0, column=0, sticky="nsew") # 批量 Tab batch_host = self._tabs.tab("📦 批量处理") batch_host.grid_columnconfigure(0, weight=1) self._batch = BatchTab(batch_host, self._get_mode) self._batch.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) def _get_mode(self) -> str: return 'corner' if '直角' in self._mode_btn.get() else 'remove' # ══════════════════════════════════════════════════════════════ if __name__ == "__main__": app = App() app.mainloop()