397 lines
16 KiB
Python
397 lines
16 KiB
Python
|
|
"""
|
|||
|
|
文章处理工具 · 去AI排版 + 直角引号版
|
|||
|
|
Core logic ported from HTML/JS → Python
|
|||
|
|
GUI: CustomTkinter
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
import random
|
|||
|
|
import sys
|
|||
|
|
import customtkinter as ctk
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
# 示例文本
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
EXAMPLE_TEXT = (
|
|||
|
|
'他说:"时间会证明一切"。*这是一个星号标记*,还有下划线_test_。'
|
|||
|
|
'实际上,这段话里包含引号、星号、下划线等复杂符号。'
|
|||
|
|
'我们的工具应该能自动删除这些符号,只保留常规标点,比如逗号,句号。'
|
|||
|
|
'感叹号!问号?冒号:以及书名号《西游记》。'
|
|||
|
|
'长句子超过十五个字符的时候,我们会在逗号处强制拆分成短句。'
|
|||
|
|
'另外,段落会随机分成2-4句一段,并且相邻段落的句子数量不会相同。'
|
|||
|
|
'每个段落内部,除了最后一个句子末尾是句号,其他句子末尾都会变成逗号。'
|
|||
|
|
'这是一个特别长的句子,它里面有很多内容,比如我们故意写很多字,'
|
|||
|
|
'让它超过十五个字符,并且中间有逗号,那么它就会被拆开。'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
# 核心处理逻辑(移植自 JS)
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def convert_quotes_to_corner(text: str) -> str:
|
|||
|
|
"""双引号 → 直角引号「」(DeepSeek版专用)"""
|
|||
|
|
# 全角左右双引号
|
|||
|
|
text = text.replace('\u201c', '「').replace('\u201d', '」')
|
|||
|
|
# 全角双引号变体 "(U+FF02) → 半角预备配对
|
|||
|
|
text = text.replace('\uff02', '"')
|
|||
|
|
# 半角双引号顺序配对
|
|||
|
|
out, left_flag = [], True
|
|||
|
|
for ch in text:
|
|||
|
|
if ch == '"':
|
|||
|
|
out.append('「' if left_flag else '」')
|
|||
|
|
left_flag = not left_flag
|
|||
|
|
else:
|
|||
|
|
out.append(ch)
|
|||
|
|
return ''.join(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clean_symbols(text: str, keep_corner: bool = False) -> str:
|
|||
|
|
"""过滤复杂符号,只保留汉字 / 数字 / 字母 / 常规标点"""
|
|||
|
|
if keep_corner:
|
|||
|
|
pattern = r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:「」]'
|
|||
|
|
else:
|
|||
|
|
pattern = r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:]'
|
|||
|
|
return re.sub(pattern, '', text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_long_sentence(sentence: str) -> list[str]:
|
|||
|
|
"""长句强制拆分(递归):长度 > 15 且含全角逗号 → 在第一个逗号处断开"""
|
|||
|
|
s = sentence.strip()
|
|||
|
|
if len(s) <= 15:
|
|||
|
|
return [s]
|
|||
|
|
comma_pos = -1
|
|||
|
|
for i, ch in enumerate(s):
|
|||
|
|
if ch == ',' and 2 < i < len(s) - 2:
|
|||
|
|
comma_pos = i
|
|||
|
|
break
|
|||
|
|
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[str]:
|
|||
|
|
"""提取句子列表"""
|
|||
|
|
matches = re.findall(r'[^。!?!?]+[。!?!?]', text)
|
|||
|
|
return [s.strip() for s in matches if s.strip()]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def group_paragraphs(sentences: list[str]) -> list[str]:
|
|||
|
|
"""段落分组:每段 2~4 句,相邻段句数不同"""
|
|||
|
|
if not sentences:
|
|||
|
|
return []
|
|||
|
|
paras, i, total = [], 0, len(sentences)
|
|||
|
|
while i < total:
|
|||
|
|
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:
|
|||
|
|
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
|
|||
|
|
paragraphs = group_paragraphs(sentences)
|
|||
|
|
return '\n\n'.join(unify_punctuation(p) for p in paragraphs)
|
|||
|
|
except Exception as e:
|
|||
|
|
return f'处理出错:{e}'
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
# GUI
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
# 配色常量
|
|||
|
|
C_GREEN = "#1f6e43"
|
|||
|
|
C_GREEN_HOVER = "#155a38"
|
|||
|
|
C_BLUE = "#2c6e8f"
|
|||
|
|
C_BLUE_HOVER = "#1f5c7a"
|
|||
|
|
C_SLATE = "#4a6f88"
|
|||
|
|
C_SLATE_HOVER = "#375a70"
|
|||
|
|
|
|||
|
|
ctk.set_appearance_mode("System")
|
|||
|
|
ctk.set_default_color_theme("blue")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Toast(ctk.CTkToplevel):
|
|||
|
|
"""轻量 toast 弹窗(1.5 s 自动关闭)"""
|
|||
|
|
def __init__(self, parent, msg: str):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.title("")
|
|||
|
|
self.geometry("300x70")
|
|||
|
|
self.resizable(False, False)
|
|||
|
|
self.grab_set()
|
|||
|
|
# 居中到父窗口
|
|||
|
|
px = parent.winfo_rootx() + parent.winfo_width() // 2 - 150
|
|||
|
|
py = parent.winfo_rooty() + parent.winfo_height() // 2 - 35
|
|||
|
|
self.geometry(f"+{px}+{py}")
|
|||
|
|
ctk.CTkLabel(self, text=msg, font=ctk.CTkFont(size=13)).pack(expand=True)
|
|||
|
|
self.after(1500, self.destroy)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class App(ctk.CTk):
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.title("✍️ 文章处理工具")
|
|||
|
|
self.geometry("1280x800")
|
|||
|
|
self.minsize(900, 620)
|
|||
|
|
self._build_ui()
|
|||
|
|
|
|||
|
|
# ── 构建界面 ──────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _build_ui(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")
|
|||
|
|
|
|||
|
|
sub = ctk.CTkLabel(
|
|||
|
|
hero,
|
|||
|
|
text="过滤符号 · 长句拆分 · 段落分组(2~4句)· 末句句号,其余逗号",
|
|||
|
|
font=ctk.CTkFont(size=12),
|
|||
|
|
text_color=("gray40", "gray70"),
|
|||
|
|
)
|
|||
|
|
sub.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=280,
|
|||
|
|
)
|
|||
|
|
self._mode_btn.set("🧹 去AI排版")
|
|||
|
|
self._mode_btn.grid(row=0, column=2, padx=20, pady=14, sticky="e")
|
|||
|
|
|
|||
|
|
# ── 两列文本区 ─────────────────────────────────────────
|
|||
|
|
columns = ctk.CTkFrame(self, fg_color="transparent")
|
|||
|
|
columns.grid(row=1, column=0, sticky="nsew", padx=20, pady=0)
|
|||
|
|
columns.grid_columnconfigure(0, weight=1)
|
|||
|
|
columns.grid_columnconfigure(1, weight=1)
|
|||
|
|
columns.grid_rowconfigure(0, weight=1)
|
|||
|
|
|
|||
|
|
# 左列
|
|||
|
|
left = ctk.CTkFrame(columns, corner_radius=18)
|
|||
|
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
|||
|
|
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 text, 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=text, 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(columns, corner_radius=18)
|
|||
|
|
right.grid(row=0, column=1, sticky="nsew", padx=(10, 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=2, column=0, pady=(6, 18))
|
|||
|
|
|
|||
|
|
ctk.CTkButton(
|
|||
|
|
bar, text="✨ 一键调整", width=180, height=48,
|
|||
|
|
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=48,
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
# ── 辅助方法 ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _get_mode(self) -> str:
|
|||
|
|
return 'corner' if '直角' in self._mode_btn.get() else 'remove'
|
|||
|
|
|
|||
|
|
def _get_source(self) -> str:
|
|||
|
|
return self._src.get("1.0", "end-1c")
|
|||
|
|
|
|||
|
|
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: str):
|
|||
|
|
Toast(self, 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._get_source()
|
|||
|
|
if not content.strip():
|
|||
|
|
self._toast("原文为空,无可复制内容。")
|
|||
|
|
return
|
|||
|
|
self.clipboard_clear()
|
|||
|
|
self.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
|
|||
|
|
self.clipboard_clear()
|
|||
|
|
self.clipboard_append(content)
|
|||
|
|
self._toast("✅ 结果已复制")
|
|||
|
|
|
|||
|
|
def _do_transform(self):
|
|||
|
|
src = self._get_source()
|
|||
|
|
if not src.strip():
|
|||
|
|
self._set_result("⚠️ 左侧无内容,请粘贴文章或点击「加载示例」。")
|
|||
|
|
return
|
|||
|
|
result = transform(src, self._get_mode())
|
|||
|
|
self._set_result(result)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
app = App()
|
|||
|
|
app.mainloop()
|