JiangAI/JiangAIP.py
2026-05-22 18:52:43 +08:00

833 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
文章处理工具 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()