bio_edit_qt6.py 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. # -*- coding: utf-8 -*-
  2. # Use PyQt6 imports
  3. import sys
  4. import yaml
  5. from pathlib import Path
  6. import copy
  7. import itertools
  8. import re
  9. # Change imports from PySide6 to PyQt6
  10. from PyQt6 import QtWidgets, QtCore, QtGui
  11. from PyQt6.QtWidgets import (
  12. QApplication,
  13. QMainWindow,
  14. QWidget,
  15. QVBoxLayout,
  16. QHBoxLayout,
  17. QTextEdit,
  18. QLabel,
  19. QPushButton,
  20. QComboBox,
  21. QLineEdit,
  22. QSplitter,
  23. QScrollArea,
  24. QFrame,
  25. QStatusBar,
  26. QMenu,
  27. QInputDialog,
  28. QMessageBox,
  29. QToolButton,
  30. QSizePolicy,
  31. QLayout,
  32. )
  33. from PyQt6.QtCore import (
  34. Qt,
  35. QSize,
  36. QPoint,
  37. pyqtSignal as Signal, # Use pyqtSignal directly
  38. QRect,
  39. QMargins,
  40. QTimer,
  41. )
  42. from PyQt6.QtGui import (
  43. QColor,
  44. QTextCharFormat,
  45. QFont,
  46. QAction,
  47. QIcon,
  48. QPalette,
  49. QTextCursor,
  50. QCloseEvent,
  51. )
  52. # --- Constants ---
  53. INITIAL_RIGHT_COL_WIDTH = 510
  54. YAML_DATA_FILE = "ds_patents_result.yml"
  55. TEXT_KEY_NAME = "摘要"
  56. ENTITY_KEY_NAME = "结果"
  57. # +++ Fixed Categories and Colors +++
  58. FIXED_CATEGORIES = [
  59. "材料类",
  60. "制造工艺类",
  61. "结构部件类",
  62. "技术特征类",
  63. "技术参数类",
  64. "特殊构造类",
  65. "功能属性类",
  66. "规范标准类",
  67. "性能指标类",
  68. "专利法律实体类",
  69. ]
  70. _color_pool_iter = itertools.cycle(
  71. [
  72. QColor("#d50000"), QColor("#2962ff"), QColor("#00c853"), QColor("#ff6d00"),
  73. QColor("#aa00ff"), QColor("#00bfa5"), QColor("#c51162"), QColor("#0091ea"),
  74. QColor("#aeea00"), QColor("#dd2c00"), QColor("#304ffe"), QColor("#00b8d4"),
  75. QColor("#ffab00"), QColor("#6200ea"), QColor("#64dd17"),
  76. ]
  77. )
  78. # Create a fixed mapping
  79. FIXED_CATEGORY_COLORS = {cat: next(_color_pool_iter) for cat in FIXED_CATEGORIES}
  80. DEFAULT_COLOR = QColor(Qt.GlobalColor.black)
  81. # +++ End Fixed Definitions +++
  82. # --- Default Data Sets ---
  83. default_data_sets = [
  84. {
  85. TEXT_KEY_NAME: "示例文本,机器学习。",
  86. ENTITY_KEY_NAME: {"技术特征类": ["机器学习"]},
  87. },
  88. {TEXT_KEY_NAME: "另一个例子。", ENTITY_KEY_NAME: {}},
  89. ]
  90. # --- Data Loading/Saving Functions ---
  91. def load_data_from_yaml(filepath: str = YAML_DATA_FILE) -> list:
  92. """Loads and cleans data from the YAML file."""
  93. data_path = Path(filepath)
  94. if not data_path.exists():
  95. print(f"信息:未找到 {filepath}。使用默认数据。")
  96. return copy.deepcopy(default_data_sets)
  97. try:
  98. with open(data_path, "r", encoding="utf-8") as f:
  99. loaded_data = yaml.safe_load(f)
  100. if not isinstance(loaded_data, list) or not all(
  101. isinstance(item, dict) for item in loaded_data
  102. ):
  103. print(f"警告:{filepath} 包含无效格式。使用默认数据。")
  104. return copy.deepcopy(default_data_sets)
  105. print(f"成功从 {filepath} 加载数据。")
  106. cleaned_data = []
  107. for i, item in enumerate(loaded_data):
  108. text = item.get(TEXT_KEY_NAME, "")
  109. vocab_raw = item.get(ENTITY_KEY_NAME, {})
  110. vocab_cleaned = {}
  111. if isinstance(vocab_raw, dict):
  112. for key, value in vocab_raw.items():
  113. str_key = str(key)
  114. if isinstance(value, list):
  115. vocab_cleaned[str_key] = [
  116. str(v)
  117. for v in value
  118. if isinstance(v, (str, int, float))
  119. ]
  120. elif value is None:
  121. vocab_cleaned[str_key] = []
  122. else:
  123. print(
  124. f"警告: 在条目 {i} 中跳过无效的词汇类别 '{str_key}' "
  125. f"(值类型: {type(value)})"
  126. )
  127. else:
  128. print(
  129. f"警告: 在条目 {i} 中发现无效的词汇结构 "
  130. f"(类型: {type(vocab_raw)}),将使用空词汇表。"
  131. )
  132. cleaned_data.append(
  133. {TEXT_KEY_NAME: str(text), ENTITY_KEY_NAME: vocab_cleaned}
  134. )
  135. # Ensure required keys exist & structure
  136. for i, item in enumerate(cleaned_data):
  137. if TEXT_KEY_NAME not in item:
  138. item[TEXT_KEY_NAME] = ""
  139. if ENTITY_KEY_NAME not in item:
  140. item[ENTITY_KEY_NAME] = {}
  141. if not isinstance(item[ENTITY_KEY_NAME], dict):
  142. print(f"警告: 条目 {i} 的 '{ENTITY_KEY_NAME}' 不是字典,已重置。")
  143. item[ENTITY_KEY_NAME] = {}
  144. else:
  145. for key, value in list(item[ENTITY_KEY_NAME].items()):
  146. if not isinstance(value, list):
  147. print(f"警告: 条目 {i}, 类别 '{key}' 的值不是列表,已重置。")
  148. item[ENTITY_KEY_NAME][key] = []
  149. return cleaned_data
  150. except Exception as e:
  151. print(f"错误:无法加载或解析 {filepath}: {e}. 使用默认数据。")
  152. return copy.deepcopy(default_data_sets)
  153. def save_data_to_yaml(data_to_save: list, filepath: str = YAML_DATA_FILE):
  154. """Saves data to YAML, filtering for fixed categories."""
  155. data_path = Path(filepath)
  156. if not isinstance(data_to_save, list):
  157. print("错误: 尝试保存的数据不是列表格式。保存中止。")
  158. return
  159. cleaned_data_to_save = []
  160. for i, item in enumerate(data_to_save):
  161. if (
  162. not isinstance(item, dict)
  163. or TEXT_KEY_NAME not in item
  164. or ENTITY_KEY_NAME not in item
  165. ):
  166. print(f"警告: 跳过保存格式不正确的条目 {i}。")
  167. continue
  168. text = str(item.get(TEXT_KEY_NAME, ""))
  169. vocab_raw = item.get(ENTITY_KEY_NAME, {})
  170. cleaned_vocab = {}
  171. if isinstance(vocab_raw, dict):
  172. for key, value in vocab_raw.items():
  173. str_key = str(key)
  174. if str_key in FIXED_CATEGORIES: # Only save fixed categories
  175. if isinstance(value, list):
  176. unique_strings = sorted(
  177. list(set(str(v) for v in value if v))
  178. )
  179. cleaned_vocab[str_key] = unique_strings
  180. else:
  181. cleaned_vocab[str_key] = []
  182. cleaned_data_to_save.append(
  183. {TEXT_KEY_NAME: text, ENTITY_KEY_NAME: cleaned_vocab}
  184. )
  185. try:
  186. with open(data_path, "w", encoding="utf-8") as f:
  187. yaml.dump(
  188. cleaned_data_to_save,
  189. f,
  190. allow_unicode=True,
  191. sort_keys=False,
  192. indent=2,
  193. default_flow_style=False,
  194. )
  195. except Exception as e:
  196. print(f"错误:无法保存数据到 {filepath}: {e}")
  197. # --- FlowLayout Helper Class ---
  198. class FlowLayout(QLayout):
  199. """A layout that flows widgets horizontally, wrapping as needed."""
  200. def __init__(self, parent=None, margin=-1, hspacing=-1, vspacing=-1):
  201. super().__init__(parent)
  202. if margin != -1:
  203. self.setContentsMargins(margin, margin, margin, margin)
  204. self._hspacing = hspacing
  205. self._vspacing = vspacing
  206. self._items = []
  207. def __del__(self):
  208. item = self.takeAt(0)
  209. while item:
  210. item = self.takeAt(0)
  211. def addItem(self, item):
  212. self._items.append(item)
  213. def count(self):
  214. return len(self._items)
  215. def itemAt(self, index):
  216. if 0 <= index < len(self._items):
  217. return self._items[index]
  218. return None
  219. def takeAt(self, index):
  220. if 0 <= index < len(self._items):
  221. return self._items.pop(index)
  222. return None
  223. def expandingDirections(self):
  224. return Qt.Orientation(0) # Not vertically expanding
  225. def hasHeightForWidth(self):
  226. return True
  227. def heightForWidth(self, width):
  228. return self._do_layout(QRect(0, 0, width, 0), True)
  229. def setGeometry(self, rect):
  230. super().setGeometry(rect)
  231. self._do_layout(rect, False)
  232. def sizeHint(self):
  233. return self.minimumSize()
  234. def minimumSize(self):
  235. size = QSize()
  236. for item in self._items:
  237. size = size.expandedTo(item.minimumSize())
  238. margin, _, _, _ = self.getContentsMargins()
  239. size += QSize(2 * margin, 2 * margin)
  240. return size
  241. def _do_layout(self, rect, test_only):
  242. left, top, right, bottom = self.getContentsMargins()
  243. effective_rect = rect.adjusted(+left, +top, -right, -bottom)
  244. x = effective_rect.x()
  245. y = effective_rect.y()
  246. line_height = 0
  247. hspacing = self._hspacing
  248. if hspacing == -1:
  249. hspacing = self.spacing()
  250. vspacing = self._vspacing
  251. if vspacing == -1:
  252. vspacing = self.spacing()
  253. for item in self._items:
  254. wid = item.widget()
  255. space_x = hspacing
  256. if not wid:
  257. space_x = 0
  258. # Simplified spacing calculation
  259. space_y = vspacing
  260. next_x = x + item.sizeHint().width() + space_x
  261. if next_x - space_x > effective_rect.right() and line_height > 0:
  262. x = effective_rect.x()
  263. y = y + line_height + space_y
  264. next_x = x + item.sizeHint().width() + space_x
  265. line_height = 0
  266. if not test_only:
  267. item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
  268. x = next_x
  269. line_height = max(line_height, item.sizeHint().height())
  270. _, top, _, bottom = self.getContentsMargins()
  271. return y + line_height - rect.y() + bottom
  272. def clear(self):
  273. """Removes all items from the layout and deletes their widgets."""
  274. while self.count():
  275. item = self.takeAt(0)
  276. widget = item.widget()
  277. if widget:
  278. widget.deleteLater()
  279. # --- Main Application Window ---
  280. class VocabHighlighterApp(QMainWindow):
  281. """Main application window class."""
  282. def __init__(self):
  283. super().__init__()
  284. self.setWindowTitle("中文文本词汇高亮工具 (PyQt6 - 固定分类)")
  285. self.setGeometry(100, 100, 1050, 700)
  286. # --- State ---
  287. self.data_sets = []
  288. self.current_data_index = -1
  289. self.current_text_content = ""
  290. self.current_vocab = {}
  291. self.original_text = ""
  292. self.original_vocab = {}
  293. self.is_modified = False
  294. # --- UI Elements ---
  295. self.text_display: QTextEdit = None
  296. self.text_editor: QTextEdit = None
  297. self.vocab_list_widget: QWidget = None
  298. self.vocab_list_layout: QVBoxLayout = None
  299. self.list_selector: QComboBox = None
  300. self.new_word_input: QLineEdit = None
  301. self.index_display_label: QLabel = None
  302. self.status_bar: QStatusBar = None
  303. self.splitter: QSplitter = None
  304. self.prev_button: QToolButton = None
  305. self.next_button: QToolButton = None
  306. self.add_word_button: QPushButton = None
  307. # --- Load Data ---
  308. self.data_sets = load_data_from_yaml()
  309. # --- Initialize UI ---
  310. self.initUI()
  311. # --- Load Initial Data ---
  312. print("正在加载初始数据...")
  313. initial_index = 0 if self.data_sets else -1
  314. self.load_data_set(initial_index)
  315. def initUI(self):
  316. """Initializes the user interface elements and layout."""
  317. # --- Central Widget and Main Splitter ---
  318. central_widget = QWidget(self)
  319. self.setCentralWidget(central_widget)
  320. main_layout = QHBoxLayout(central_widget)
  321. self.splitter = QSplitter(Qt.Orientation.Horizontal, self)
  322. main_layout.addWidget(self.splitter)
  323. # --- Left Panel ---
  324. left_panel = QWidget()
  325. left_layout = QVBoxLayout(left_panel)
  326. left_layout.setContentsMargins(5, 5, 5, 5)
  327. left_layout.setSpacing(5)
  328. # Text Display
  329. left_layout.addWidget(QLabel("显示(高亮):"))
  330. self.text_display = QTextEdit()
  331. self.text_display.setReadOnly(True)
  332. self.text_display.setFixedHeight(200)
  333. self.text_display.setObjectName("TextDisplay")
  334. self.text_display.selectionChanged.connect(
  335. self.on_display_selection_changed
  336. )
  337. left_layout.addWidget(self.text_display)
  338. # Navigation
  339. nav_layout = QHBoxLayout()
  340. self.prev_button = QToolButton()
  341. style = self.style()
  342. self.prev_button.setIcon(
  343. style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft)
  344. )
  345. self.prev_button.setToolTip("上一条 (若有修改则保存)")
  346. self.prev_button.clicked.connect(self.go_previous)
  347. nav_layout.addWidget(self.prev_button)
  348. self.index_display_label = QLabel("? / ?")
  349. self.index_display_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
  350. nav_layout.addWidget(self.index_display_label, 1) # Stretch
  351. self.next_button = QToolButton()
  352. self.next_button.setIcon(
  353. style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowRight)
  354. )
  355. self.next_button.setToolTip("下一条 (若有修改则保存)")
  356. self.next_button.clicked.connect(self.go_next)
  357. nav_layout.addWidget(self.next_button)
  358. left_layout.addLayout(nav_layout)
  359. # Text Editor
  360. left_layout.addWidget(QLabel("编辑中文文本:"))
  361. self.text_editor = QTextEdit()
  362. self.text_editor.setObjectName("TextEditor")
  363. self.text_editor.textChanged.connect(self.on_text_edited)
  364. self.text_editor.selectionChanged.connect(
  365. self.on_editor_selection_changed
  366. )
  367. self.text_editor.setContextMenuPolicy(
  368. Qt.ContextMenuPolicy.CustomContextMenu
  369. )
  370. self.text_editor.customContextMenuRequested.connect(
  371. self.show_editor_context_menu
  372. )
  373. left_layout.addWidget(self.text_editor, 1) # Vertical stretch
  374. # Add Word Section
  375. divider = QFrame()
  376. divider.setFrameShape(QFrame.Shape.HLine)
  377. divider.setFrameShadow(QFrame.Shadow.Sunken)
  378. left_layout.addWidget(divider)
  379. left_layout.addWidget(QLabel("添加词汇:"))
  380. add_word_layout = QHBoxLayout()
  381. self.new_word_input = QLineEdit()
  382. self.new_word_input.setPlaceholderText("要添加的词汇")
  383. self.new_word_input.returnPressed.connect(self.add_word)
  384. add_word_layout.addWidget(self.new_word_input, 1) # Stretch
  385. self.list_selector = QComboBox()
  386. self.list_selector.addItems(FIXED_CATEGORIES) # Populate fixed list
  387. add_word_layout.addWidget(self.list_selector, 1) # Stretch
  388. self.add_word_button = QPushButton("添加")
  389. self.add_word_button.setToolTip("添加到所选列表")
  390. self.add_word_button.clicked.connect(self.add_word)
  391. add_word_layout.addWidget(self.add_word_button)
  392. left_layout.addLayout(add_word_layout)
  393. self.splitter.addWidget(left_panel)
  394. # --- Right Panel ---
  395. right_panel = QWidget()
  396. right_layout = QVBoxLayout(right_panel)
  397. right_layout.setContentsMargins(5, 5, 5, 5)
  398. right_layout.setSpacing(10)
  399. right_layout.addWidget(QLabel("词汇列表 (固定分类):"))
  400. scroll_area = QScrollArea()
  401. scroll_area.setWidgetResizable(True)
  402. scroll_area.setFrameShape(QFrame.Shape.StyledPanel)
  403. self.vocab_list_widget = QWidget()
  404. self.vocab_list_layout = QVBoxLayout(self.vocab_list_widget)
  405. self.vocab_list_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
  406. scroll_area.setWidget(self.vocab_list_widget)
  407. right_layout.addWidget(scroll_area, 1) # Vertical stretch
  408. self.splitter.addWidget(right_panel)
  409. # --- Splitter & Status Bar ---
  410. total_width = 1050
  411. right_width = INITIAL_RIGHT_COL_WIDTH
  412. left_width = total_width - right_width - self.splitter.handleWidth()
  413. self.splitter.setSizes([max(150, left_width), max(150, right_width)])
  414. self.splitter.setStretchFactor(0, 1)
  415. self.splitter.setStretchFactor(1, 0)
  416. self.status_bar = QStatusBar()
  417. self.setStatusBar(self.status_bar)
  418. # --- Data Handling and State ---
  419. def load_data_set(self, index):
  420. """Loads a dataset into the UI and state."""
  421. if not self.data_sets or not (0 <= index < len(self.data_sets)):
  422. print("无效索引或无数据,清空 UI。")
  423. self.current_data_index = -1
  424. self.current_text_content = ""
  425. self.current_vocab = {}
  426. self.original_text = ""
  427. self.original_vocab = {}
  428. self.is_modified = False
  429. self.update_ui()
  430. return
  431. self.current_data_index = index
  432. data = self.data_sets[index]
  433. loaded_text = str(data.get(TEXT_KEY_NAME, ""))
  434. vocab_raw = data.get(ENTITY_KEY_NAME, {})
  435. loaded_vocab = {}
  436. if isinstance(vocab_raw, dict):
  437. loaded_vocab = {
  438. str(k): [
  439. str(v) for v in (val if isinstance(val, list) else []) if v
  440. ]
  441. for k, val in vocab_raw.items()
  442. }
  443. else:
  444. print(f"警告:条目 {index} 的词汇表格式无效,已重置。")
  445. self.current_text_content = loaded_text
  446. self.current_vocab = loaded_vocab
  447. self.original_text = loaded_text
  448. self.original_vocab = copy.deepcopy(loaded_vocab)
  449. self.is_modified = False
  450. print(f"已加载条目 {index + 1} / {len(self.data_sets)}")
  451. self.update_ui()
  452. self.show_status_message(f"已加载条目 {index + 1}")
  453. def check_if_modified(self) -> bool:
  454. """Checks if current data differs from the originally loaded data."""
  455. if self.current_data_index < 0:
  456. return False
  457. editor_text = self.text_editor.toPlainText()
  458. text_modified = editor_text != self.original_text
  459. # Compare only the fixed categories for modification checks
  460. current_vocab_filtered = {
  461. k: sorted(list(set(str(w) for w in v if w)))
  462. for k, v in self.current_vocab.items()
  463. if k in FIXED_CATEGORIES and isinstance(v, list)
  464. }
  465. original_vocab_filtered = {
  466. k: sorted(list(set(str(w) for w in v if w)))
  467. for k, v in self.original_vocab.items()
  468. if k in FIXED_CATEGORIES and isinstance(v, list)
  469. }
  470. vocab_modified = current_vocab_filtered != original_vocab_filtered
  471. return self.is_modified or text_modified or vocab_modified
  472. def save_current_if_modified(self) -> bool:
  473. """Saves the current item (only fixed categories) if modified."""
  474. editor_text = self.text_editor.toPlainText()
  475. if editor_text != self.current_text_content:
  476. self.current_text_content = editor_text
  477. self.is_modified = True # Mark if text changed
  478. if self.current_data_index < 0 or not self.check_if_modified():
  479. return False
  480. idx = self.current_data_index
  481. print(f"检测到更改,正在保存条目 {idx + 1} (仅固定分类)...")
  482. # Filter vocabulary to save only fixed categories
  483. cleaned_vocab_save = {}
  484. for category, words in self.current_vocab.items():
  485. str_key = str(category)
  486. if str_key in FIXED_CATEGORIES:
  487. if isinstance(words, list):
  488. cleaned_vocab_save[str_key] = sorted(
  489. list(set(str(w) for w in words if w))
  490. )
  491. else:
  492. cleaned_vocab_save[str_key] = []
  493. # Save the filtered vocab back to the main data list
  494. self.data_sets[idx][TEXT_KEY_NAME] = self.current_text_content
  495. self.data_sets[idx][ENTITY_KEY_NAME] = cleaned_vocab_save
  496. # Call the actual file saving function
  497. save_data_to_yaml(self.data_sets)
  498. print(f"数据已成功保存到 {YAML_DATA_FILE}")
  499. self.show_status_message(f"条目 {idx + 1} 已保存", 3000)
  500. # Update original state *after* successful save
  501. self.original_text = self.current_text_content
  502. # Store deep copy of potentially modified current_vocab as new original
  503. self.original_vocab = copy.deepcopy(self.current_vocab)
  504. self.is_modified = False # Reset modification status
  505. self.setWindowTitle(f"中文文本词汇高亮工具 - 条目 {self.current_data_index + 1}")
  506. return True
  507. # --- UI Update Functions ---
  508. def update_ui(self):
  509. """Refreshes all relevant UI elements based on current state."""
  510. if self.current_data_index < 0:
  511. self.text_display.setHtml("")
  512. self.text_editor.blockSignals(True)
  513. self.text_editor.setPlainText("")
  514. self.text_editor.blockSignals(False)
  515. self.index_display_label.setText("0 / 0")
  516. self.clear_vocab_display()
  517. self.prev_button.setEnabled(False)
  518. self.next_button.setEnabled(False)
  519. self.setWindowTitle("中文文本词汇高亮工具 (PyQt6 - 固定分类)")
  520. else:
  521. html_text = self.build_highlighted_html(
  522. self.current_text_content, self.current_vocab
  523. )
  524. self.text_display.setHtml(html_text)
  525. current_editor_text = self.text_editor.toPlainText()
  526. if current_editor_text != self.current_text_content:
  527. self.text_editor.blockSignals(True)
  528. self.text_editor.setPlainText(self.current_text_content)
  529. self.text_editor.blockSignals(False)
  530. self.index_display_label.setText(
  531. f"{self.current_data_index + 1} / {len(self.data_sets)}"
  532. )
  533. self.build_vocab_display() # Update right panel vocab list
  534. # list_selector already populated with fixed categories
  535. can_navigate = len(self.data_sets) > 1
  536. self.prev_button.setEnabled(can_navigate)
  537. self.next_button.setEnabled(can_navigate)
  538. title = f"中文文本词汇高亮工具 - 条目 {self.current_data_index + 1}"
  539. if self.check_if_modified():
  540. title += " *"
  541. self.setWindowTitle(title)
  542. def build_highlighted_html(self, text_content, vocab_dict) -> str:
  543. """Creates HTML string using fixed colors for known categories."""
  544. if not isinstance(text_content, str):
  545. text_content = ""
  546. if not isinstance(vocab_dict, dict) or not text_content:
  547. return text_content
  548. escaped_text = (
  549. text_content.replace("&", "&")
  550. .replace("<", "<")
  551. .replace(">", ">")
  552. )
  553. matches = []
  554. for list_name_orig, words in vocab_dict.items():
  555. list_name = str(list_name_orig)
  556. color = FIXED_CATEGORY_COLORS.get(list_name, DEFAULT_COLOR)
  557. hex_color = color.name()
  558. if isinstance(words, list):
  559. sorted_words = sorted(
  560. [str(w) for w in words if w], key=len, reverse=True
  561. )
  562. for word in sorted_words:
  563. start_index = 0
  564. while True:
  565. try:
  566. index = text_content.find(word, start_index)
  567. except ValueError:
  568. break
  569. if index == -1:
  570. break
  571. end_index = index + len(word)
  572. matches.append(
  573. (index, end_index, word, list_name, hex_color)
  574. )
  575. start_index = index + 1
  576. matches.sort(key=lambda x: (x[0], -(x[1] - x[0])))
  577. result_html = ""
  578. current_pos = 0
  579. processed_indices = set()
  580. for start, end, word, category_name, hex_color in matches:
  581. is_overlapped = any(i in processed_indices for i in range(start, end))
  582. if is_overlapped:
  583. continue
  584. if start > current_pos:
  585. result_html += escaped_text[current_pos:start]
  586. escaped_word = (
  587. word.replace("&", "&")
  588. .replace("<", "<")
  589. .replace(">", ">")
  590. )
  591. result_html += (
  592. f'<span style="color: {hex_color}; font-weight: bold;">'
  593. f"{escaped_word}</span>"
  594. )
  595. current_pos = end
  596. processed_indices.update(range(start, end))
  597. if current_pos < len(escaped_text):
  598. result_html += escaped_text[current_pos:]
  599. return result_html
  600. def clear_vocab_display(self):
  601. """Clears the vocab display area."""
  602. if not self.vocab_list_layout:
  603. return
  604. while self.vocab_list_layout.count():
  605. layout_item = self.vocab_list_layout.takeAt(0)
  606. if layout_item is None:
  607. continue
  608. widget = layout_item.widget()
  609. if widget:
  610. widget.deleteLater()
  611. else:
  612. # Handle layouts within the main layout (e.g., FlowLayout)
  613. layout_to_clear = layout_item.layout()
  614. if layout_to_clear and isinstance(layout_to_clear, FlowLayout):
  615. layout_to_clear.clear()
  616. def build_vocab_display(self):
  617. """Populates the right panel based on FIXED categories."""
  618. self.clear_vocab_display()
  619. if not isinstance(self.current_vocab, dict):
  620. return
  621. # Iterate through the FIXED list to control display order
  622. for list_name in FIXED_CATEGORIES:
  623. words = self.current_vocab.get(list_name, [])
  624. color = FIXED_CATEGORY_COLORS.get(list_name, DEFAULT_COLOR)
  625. title_label = QLabel(list_name)
  626. title_font = QFont()
  627. title_font.setBold(True)
  628. title_font.setPointSize(12)
  629. title_label.setFont(title_font)
  630. title_label.setStyleSheet(f"color: {color.name()};")
  631. self.vocab_list_layout.addWidget(title_label)
  632. words_container = QWidget()
  633. flow_layout = FlowLayout(
  634. words_container, margin=0, hspacing=4, vspacing=2
  635. )
  636. words_list = words if isinstance(words, list) else []
  637. unique_words = sorted(list(set(str(w) for w in words_list if w)))
  638. if not unique_words:
  639. empty_label = QLabel("(空列表)")
  640. empty_label.setStyleSheet("color: gray; font-style: italic;")
  641. flow_layout.addWidget(empty_label)
  642. else:
  643. for word in unique_words:
  644. word_label = QLabel(word)
  645. word_label.setStyleSheet(f"color: {color.name()};")
  646. word_label.setTextInteractionFlags(
  647. Qt.TextInteractionFlag.TextSelectableByMouse
  648. )
  649. # Add Context Menu Policy and Connection
  650. word_label.setContextMenuPolicy(
  651. Qt.ContextMenuPolicy.CustomContextMenu
  652. )
  653. word_label.customContextMenuRequested.connect(
  654. lambda pos, w=word, cat=list_name, lbl=word_label: self.show_word_context_menu(
  655. pos, w, cat, lbl
  656. )
  657. )
  658. flow_layout.addWidget(word_label)
  659. self.vocab_list_layout.addWidget(words_container)
  660. spacer = QFrame()
  661. spacer.setFrameShape(QFrame.Shape.HLine)
  662. spacer.setFrameShadow(QFrame.Shadow.Sunken)
  663. spacer.setFixedHeight(5)
  664. self.vocab_list_layout.addWidget(spacer)
  665. # --- Event Handlers (Slots) ---
  666. def on_text_edited(self):
  667. """Flags modification when text editor content changes."""
  668. self.is_modified = True
  669. if self.current_data_index >= 0 and not self.windowTitle().endswith(" *"):
  670. self.setWindowTitle(self.windowTitle() + " *")
  671. def on_editor_selection_changed(self):
  672. """Copies selected editor text to input."""
  673. if not self.text_editor or not self.new_word_input:
  674. return
  675. cursor = self.text_editor.textCursor()
  676. selected_text = cursor.selectedText().strip()
  677. self.new_word_input.blockSignals(True)
  678. self.new_word_input.setText(selected_text)
  679. self.new_word_input.blockSignals(False)
  680. def on_display_selection_changed(self):
  681. """Copies selected display text to input."""
  682. if not self.text_display or not self.new_word_input:
  683. return
  684. cursor = self.text_display.textCursor()
  685. selected_text = cursor.selectedText().strip()
  686. self.new_word_input.blockSignals(True)
  687. self.new_word_input.setText(selected_text)
  688. self.new_word_input.blockSignals(False)
  689. def go_next(self):
  690. """Navigates to the next item, saving if modified."""
  691. if not self.data_sets:
  692. return
  693. if self.save_current_if_modified():
  694. QTimer.singleShot(10, self._navigate_next)
  695. else:
  696. self._navigate_next()
  697. def _navigate_next(self):
  698. """Helper to perform the actual next navigation."""
  699. if not self.data_sets:
  700. return
  701. next_index = (self.current_data_index + 1) % len(self.data_sets)
  702. self.load_data_set(next_index)
  703. def go_previous(self):
  704. """Navigates to the previous item, saving if modified."""
  705. if not self.data_sets:
  706. return
  707. if self.save_current_if_modified():
  708. QTimer.singleShot(10, self._navigate_previous)
  709. else:
  710. self._navigate_previous()
  711. def _navigate_previous(self):
  712. """Helper to perform the actual previous navigation."""
  713. if not self.data_sets:
  714. return
  715. prev_index = (self.current_data_index - 1 + len(self.data_sets)) % len(
  716. self.data_sets
  717. )
  718. self.load_data_set(prev_index)
  719. def add_word(self, word_to_add=None, selected_list=None) -> bool:
  720. """Adds a word to the selected FIXED category."""
  721. if self.current_data_index < 0:
  722. self.show_status_message("错误:没有加载的条目可添加词汇。", is_error=True)
  723. return False
  724. word = (
  725. word_to_add
  726. if isinstance(word_to_add, str)
  727. else self.new_word_input.text().strip()
  728. )
  729. target_list_key = (
  730. selected_list
  731. if isinstance(selected_list, str)
  732. else self.list_selector.currentText()
  733. )
  734. if not word:
  735. self.show_status_message("错误:要添加的词汇不能为空!", is_error=True)
  736. if not isinstance(word_to_add, str):
  737. self.new_word_input.setFocus()
  738. return False
  739. if not target_list_key or target_list_key not in FIXED_CATEGORIES:
  740. self.show_status_message(
  741. f"错误:无效的目标列表 '{target_list_key}'!", is_error=True
  742. )
  743. if not isinstance(selected_list, str):
  744. self.list_selector.setFocus()
  745. return False
  746. # Ensure the category exists in current_vocab and is a list
  747. if target_list_key not in self.current_vocab or not isinstance(
  748. self.current_vocab.get(target_list_key), list
  749. ):
  750. self.current_vocab[target_list_key] = []
  751. word_str = str(word)
  752. existing_words_str = {
  753. str(w) for w in self.current_vocab[target_list_key]
  754. }
  755. if word_str not in existing_words_str:
  756. self.current_vocab[target_list_key].append(word_str)
  757. self.current_vocab[target_list_key].sort(key=str)
  758. if not isinstance(word_to_add, str):
  759. self.new_word_input.clear()
  760. self.is_modified = True
  761. self.update_ui()
  762. self.show_status_message(
  763. f"'{word_str}' 已添加到 '{target_list_key}' (未保存)"
  764. )
  765. if not isinstance(word_to_add, str):
  766. self.new_word_input.setFocus()
  767. return True
  768. else:
  769. self.show_status_message(f"'{word_str}' 已存在于 '{target_list_key}'")
  770. if not isinstance(word_to_add, str):
  771. self.new_word_input.selectAll()
  772. self.new_word_input.setFocus()
  773. return False
  774. def show_status_message(
  775. self, message: str, timeout: int = 5000, is_error: bool = False
  776. ):
  777. """Displays a message in the status bar."""
  778. if self.status_bar:
  779. style_sheet = "color: red;" if is_error else ""
  780. self.status_bar.setStyleSheet(style_sheet)
  781. self.status_bar.showMessage(message, timeout)
  782. # --- Context Menu Handlers ---
  783. def show_editor_context_menu(self, position: QPoint):
  784. """Shows the right-click context menu (Create New List removed)."""
  785. menu = QMenu(self)
  786. selected_text = self.text_editor.textCursor().selectedText()
  787. has_selection = bool(selected_text)
  788. add_to_menu = QMenu("添加到列表...", menu)
  789. add_to_menu.setEnabled(has_selection)
  790. if has_selection:
  791. for list_name in FIXED_CATEGORIES:
  792. action = QtGui.QAction(list_name, self)
  793. action.triggered.connect(
  794. lambda checked=False, text=selected_text, name=list_name: self.add_word(
  795. word_to_add=text, selected_list=name
  796. )
  797. )
  798. add_to_menu.addAction(action)
  799. menu.addMenu(add_to_menu)
  800. # Create New List action removed
  801. menu.addSeparator()
  802. copy_action = QtGui.QAction("复制", self)
  803. copy_action.setEnabled(has_selection)
  804. copy_action.triggered.connect(self.text_editor.copy)
  805. menu.addAction(copy_action)
  806. paste_action = QtGui.QAction("粘贴", self)
  807. paste_action.triggered.connect(self.text_editor.paste)
  808. menu.addAction(paste_action)
  809. global_pos = self.text_editor.mapToGlobal(position)
  810. menu.exec(global_pos)
  811. def show_word_context_menu(
  812. self, position: QPoint, word_to_delete: str, category_name: str, word_label_widget: QLabel
  813. ):
  814. """Shows context menu for a specific word label in the vocab list."""
  815. menu = QMenu(self)
  816. delete_action = QtGui.QAction(f"删除词汇 '{word_to_delete}'", self)
  817. delete_action.triggered.connect(
  818. lambda checked=False, w=word_to_delete, cat=category_name: self.delete_vocab_word(
  819. w, cat
  820. )
  821. )
  822. menu.addAction(delete_action)
  823. global_pos = word_label_widget.mapToGlobal(position)
  824. menu.exec(global_pos)
  825. def delete_vocab_word(self, word_to_delete: str, category_name: str):
  826. """Removes a specific word from a category list."""
  827. if self.current_data_index < 0:
  828. self.show_status_message("错误:没有加载的条目可删除词汇。", is_error=True)
  829. return
  830. if not isinstance(self.current_vocab, dict):
  831. print("错误:当前词汇数据无效,无法删除。")
  832. return
  833. if category_name in self.current_vocab and isinstance(
  834. self.current_vocab[category_name], list
  835. ):
  836. try:
  837. word_str_to_delete = str(word_to_delete)
  838. item_to_remove = next(
  839. item
  840. for item in self.current_vocab[category_name]
  841. if str(item) == word_str_to_delete
  842. )
  843. self.current_vocab[category_name].remove(item_to_remove)
  844. self.is_modified = True
  845. self.update_ui()
  846. self.show_status_message(
  847. f"词汇 '{word_to_delete}' 已从 '{category_name}' 删除 (未保存)"
  848. )
  849. except (ValueError, StopIteration):
  850. print(
  851. f"警告:尝试删除时未在列表 '{category_name}' "
  852. f"中找到词汇 '{word_to_delete}'。"
  853. )
  854. self.show_status_message(
  855. f"错误:未找到词汇 '{word_to_delete}'", is_error=True
  856. )
  857. else:
  858. print(f"警告:尝试删除词汇时未找到分类列表 '{category_name}'。")
  859. self.show_status_message(
  860. f"错误:未找到分类 '{category_name}'", is_error=True
  861. )
  862. # create_new_list method removed
  863. # --- Window Close Event ---
  864. def closeEvent(self, event: QCloseEvent):
  865. """Handles the window close request."""
  866. print("窗口关闭事件触发。正在检查最终保存...")
  867. if self.check_if_modified():
  868. if self.save_current_if_modified():
  869. print("更改已保存。")
  870. event.accept()
  871. else:
  872. # Save failed or check_if_modified still true after attempt
  873. reply = QMessageBox.question(
  874. self,
  875. "退出确认",
  876. "当前条目有未保存的更改。确定要退出吗?",
  877. QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
  878. QMessageBox.StandardButton.No,
  879. )
  880. if reply == QMessageBox.StandardButton.Yes:
  881. print("未保存更改,用户选择退出。")
  882. event.accept()
  883. else:
  884. print("退出操作已取消。")
  885. event.ignore()
  886. else:
  887. print("无未保存更改,正常退出。")
  888. event.accept()
  889. # --- Run Application ---
  890. if __name__ == "__main__":
  891. app = QApplication(sys.argv)
  892. # app.setStyle('Fusion') # Optional styling
  893. main_window = VocabHighlighterApp()
  894. main_window.show()
  895. sys.exit(app.exec())