supervertaler 1.9.163__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Supervertaler.py +48473 -0
- modules/__init__.py +10 -0
- modules/ai_actions.py +964 -0
- modules/ai_attachment_manager.py +343 -0
- modules/ai_file_viewer_dialog.py +210 -0
- modules/autofingers_engine.py +466 -0
- modules/cafetran_docx_handler.py +379 -0
- modules/config_manager.py +469 -0
- modules/database_manager.py +1911 -0
- modules/database_migrations.py +417 -0
- modules/dejavurtf_handler.py +779 -0
- modules/document_analyzer.py +427 -0
- modules/docx_handler.py +689 -0
- modules/encoding_repair.py +319 -0
- modules/encoding_repair_Qt.py +393 -0
- modules/encoding_repair_ui.py +481 -0
- modules/feature_manager.py +350 -0
- modules/figure_context_manager.py +340 -0
- modules/file_dialog_helper.py +148 -0
- modules/find_replace.py +164 -0
- modules/find_replace_qt.py +457 -0
- modules/glossary_manager.py +433 -0
- modules/image_extractor.py +188 -0
- modules/keyboard_shortcuts_widget.py +571 -0
- modules/llm_clients.py +1211 -0
- modules/llm_leaderboard.py +737 -0
- modules/llm_superbench_ui.py +1401 -0
- modules/local_llm_setup.py +1104 -0
- modules/model_update_dialog.py +381 -0
- modules/model_version_checker.py +373 -0
- modules/mqxliff_handler.py +638 -0
- modules/non_translatables_manager.py +743 -0
- modules/pdf_rescue_Qt.py +1822 -0
- modules/pdf_rescue_tkinter.py +909 -0
- modules/phrase_docx_handler.py +516 -0
- modules/project_home_panel.py +209 -0
- modules/prompt_assistant.py +357 -0
- modules/prompt_library.py +689 -0
- modules/prompt_library_migration.py +447 -0
- modules/quick_access_sidebar.py +282 -0
- modules/ribbon_widget.py +597 -0
- modules/sdlppx_handler.py +874 -0
- modules/setup_wizard.py +353 -0
- modules/shortcut_manager.py +932 -0
- modules/simple_segmenter.py +128 -0
- modules/spellcheck_manager.py +727 -0
- modules/statuses.py +207 -0
- modules/style_guide_manager.py +315 -0
- modules/superbench_ui.py +1319 -0
- modules/superbrowser.py +329 -0
- modules/supercleaner.py +600 -0
- modules/supercleaner_ui.py +444 -0
- modules/superdocs.py +19 -0
- modules/superdocs_viewer_qt.py +382 -0
- modules/superlookup.py +252 -0
- modules/tag_cleaner.py +260 -0
- modules/tag_manager.py +351 -0
- modules/term_extractor.py +270 -0
- modules/termbase_entry_editor.py +842 -0
- modules/termbase_import_export.py +488 -0
- modules/termbase_manager.py +1060 -0
- modules/termview_widget.py +1176 -0
- modules/theme_manager.py +499 -0
- modules/tm_editor_dialog.py +99 -0
- modules/tm_manager_qt.py +1280 -0
- modules/tm_metadata_manager.py +545 -0
- modules/tmx_editor.py +1461 -0
- modules/tmx_editor_qt.py +2784 -0
- modules/tmx_generator.py +284 -0
- modules/tracked_changes.py +900 -0
- modules/trados_docx_handler.py +430 -0
- modules/translation_memory.py +715 -0
- modules/translation_results_panel.py +2134 -0
- modules/translation_services.py +282 -0
- modules/unified_prompt_library.py +659 -0
- modules/unified_prompt_manager_qt.py +3951 -0
- modules/voice_commands.py +920 -0
- modules/voice_dictation.py +477 -0
- modules/voice_dictation_lite.py +249 -0
- supervertaler-1.9.163.dist-info/METADATA +906 -0
- supervertaler-1.9.163.dist-info/RECORD +85 -0
- supervertaler-1.9.163.dist-info/WHEEL +5 -0
- supervertaler-1.9.163.dist-info/entry_points.txt +2 -0
- supervertaler-1.9.163.dist-info/licenses/LICENSE +21 -0
- supervertaler-1.9.163.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Supervertaler Feature Manager
|
|
3
|
+
=============================
|
|
4
|
+
|
|
5
|
+
Manages feature availability and user enable/disable toggles. Features can be
|
|
6
|
+
toggled in Settings → Features.
|
|
7
|
+
|
|
8
|
+
Each feature module has:
|
|
9
|
+
- Required pip packages (some are optional extras)
|
|
10
|
+
- Size estimate for user information
|
|
11
|
+
- Availability check (are dependencies installed?)
|
|
12
|
+
- Enable/disable toggle (user preference)
|
|
13
|
+
|
|
14
|
+
Installation examples:
|
|
15
|
+
pip install supervertaler # Recommended core install
|
|
16
|
+
pip install supervertaler[supermemory] # Optional: Supermemory semantic search (heavy)
|
|
17
|
+
pip install supervertaler[local-whisper] # Optional: Local Whisper (offline, heavy)
|
|
18
|
+
pip install supervertaler[all] # Legacy alias (no-op; kept for compatibility)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import importlib.util
|
|
24
|
+
import importlib
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Dict, List, Optional, Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FeatureModule:
|
|
32
|
+
"""Definition of an optional feature module."""
|
|
33
|
+
id: str
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
pip_extra: str # Name used in pip install supervertaler[extra]
|
|
37
|
+
packages: List[str] # Required pip packages
|
|
38
|
+
size_mb: int # Approximate installed size in MB
|
|
39
|
+
check_import: str # Module to import to check availability
|
|
40
|
+
icon: str = "📦"
|
|
41
|
+
category: str = "Optional"
|
|
42
|
+
enabled_by_default: bool = True
|
|
43
|
+
|
|
44
|
+
def is_available(self) -> bool:
|
|
45
|
+
"""Check if required packages are installed."""
|
|
46
|
+
# IMPORTANT: do NOT import the module here.
|
|
47
|
+
# Some optional features (e.g., sentence-transformers/torch) can be slow to import
|
|
48
|
+
# and may even crash in frozen/PyInstaller builds depending on native deps.
|
|
49
|
+
# Availability checks should be cheap and side-effect free.
|
|
50
|
+
try:
|
|
51
|
+
return importlib.util.find_spec(self.check_import) is not None
|
|
52
|
+
except (ImportError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Define all optional feature modules
|
|
57
|
+
FEATURE_MODULES: Dict[str, FeatureModule] = {
|
|
58
|
+
"voice": FeatureModule(
|
|
59
|
+
id="voice",
|
|
60
|
+
name="Supervoice (Voice Commands)",
|
|
61
|
+
description=(
|
|
62
|
+
"Voice dictation and hands-free commands. Works via the OpenAI Whisper API (recommended). "
|
|
63
|
+
"Optional offline Local Whisper is available via the 'local-whisper' extra."
|
|
64
|
+
),
|
|
65
|
+
pip_extra="voice",
|
|
66
|
+
packages=["sounddevice", "numpy", "openai"],
|
|
67
|
+
size_mb=150,
|
|
68
|
+
check_import="sounddevice",
|
|
69
|
+
icon="🎤",
|
|
70
|
+
category="AI Features",
|
|
71
|
+
enabled_by_default=False,
|
|
72
|
+
),
|
|
73
|
+
"local_whisper": FeatureModule(
|
|
74
|
+
id="local_whisper",
|
|
75
|
+
name="Local Whisper (Offline Speech Recognition)",
|
|
76
|
+
description=(
|
|
77
|
+
"Offline Whisper speech recognition (no API key required). This is a heavy dependency (PyTorch) and "
|
|
78
|
+
"may increase install size significantly. Requires FFmpeg for best results."
|
|
79
|
+
),
|
|
80
|
+
pip_extra="local-whisper",
|
|
81
|
+
packages=["openai-whisper"],
|
|
82
|
+
size_mb=1500,
|
|
83
|
+
check_import="whisper",
|
|
84
|
+
icon="🤖",
|
|
85
|
+
category="AI Features",
|
|
86
|
+
enabled_by_default=False,
|
|
87
|
+
),
|
|
88
|
+
"webengine": FeatureModule(
|
|
89
|
+
id="webengine",
|
|
90
|
+
name="Web Browser (Superlookup)",
|
|
91
|
+
description="Built-in web browser for research resources in Superlookup. Access IATE, ProZ, Linguee, Wikipedia directly from the app.",
|
|
92
|
+
pip_extra="web",
|
|
93
|
+
packages=["PyQt6-WebEngine"],
|
|
94
|
+
size_mb=100,
|
|
95
|
+
check_import="PyQt6.QtWebEngineWidgets",
|
|
96
|
+
icon="🌐",
|
|
97
|
+
category="UI Features",
|
|
98
|
+
),
|
|
99
|
+
"pdf": FeatureModule(
|
|
100
|
+
id="pdf",
|
|
101
|
+
name="PDF Rescue (OCR)",
|
|
102
|
+
description="Extract text from scanned PDFs using AI OCR. Converts locked PDFs into editable DOCX files.",
|
|
103
|
+
pip_extra="pdf",
|
|
104
|
+
packages=["PyMuPDF"],
|
|
105
|
+
size_mb=30,
|
|
106
|
+
check_import="fitz",
|
|
107
|
+
icon="📄",
|
|
108
|
+
category="Document Processing",
|
|
109
|
+
),
|
|
110
|
+
"mt_providers": FeatureModule(
|
|
111
|
+
id="mt_providers",
|
|
112
|
+
name="MT Providers (DeepL, Amazon)",
|
|
113
|
+
description="Additional machine translation providers: DeepL API and Amazon Translate. Requires API keys.",
|
|
114
|
+
pip_extra="mt",
|
|
115
|
+
packages=["boto3", "deepl"],
|
|
116
|
+
size_mb=30,
|
|
117
|
+
check_import="deepl",
|
|
118
|
+
icon="🔄",
|
|
119
|
+
category="Translation",
|
|
120
|
+
),
|
|
121
|
+
"hunspell": FeatureModule(
|
|
122
|
+
id="hunspell",
|
|
123
|
+
name="Hunspell Spellcheck",
|
|
124
|
+
description="Advanced spellcheck using Hunspell dictionaries. Supports regional variants (en-US, en-GB, etc). Uses spylls (pure Python) on Windows.",
|
|
125
|
+
pip_extra="hunspell",
|
|
126
|
+
packages=["spylls"], # Pure Python Hunspell - works on all platforms
|
|
127
|
+
size_mb=20,
|
|
128
|
+
check_import="spylls",
|
|
129
|
+
icon="📝",
|
|
130
|
+
category="Quality Assurance",
|
|
131
|
+
enabled_by_default=True, # spylls works everywhere
|
|
132
|
+
),
|
|
133
|
+
"automation": FeatureModule(
|
|
134
|
+
id="automation",
|
|
135
|
+
name="AutoFingers (Windows)",
|
|
136
|
+
description="Automated keyboard/mouse control for memoQ integration. Windows only - uses AutoHotkey.",
|
|
137
|
+
pip_extra="windows",
|
|
138
|
+
packages=["keyboard", "ahk"],
|
|
139
|
+
size_mb=10,
|
|
140
|
+
check_import="keyboard",
|
|
141
|
+
icon="🤖",
|
|
142
|
+
category="Automation",
|
|
143
|
+
enabled_by_default=False, # Windows-only
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FeatureManager:
|
|
149
|
+
"""
|
|
150
|
+
Manages feature availability and user preferences.
|
|
151
|
+
|
|
152
|
+
Usage:
|
|
153
|
+
fm = FeatureManager(user_data_path)
|
|
154
|
+
|
|
155
|
+
# Check if a feature can be used
|
|
156
|
+
if fm.is_feature_usable("supermemory"):
|
|
157
|
+
from modules.supermemory import SupermemoryEngine
|
|
158
|
+
|
|
159
|
+
# Get all features for Settings UI
|
|
160
|
+
for feature in fm.get_all_features():
|
|
161
|
+
print(f"{feature.name}: {'✅' if fm.is_feature_usable(feature.id) else '❌'}")
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, user_data_path: str = "user_data"):
|
|
165
|
+
self.user_data_path = Path(user_data_path)
|
|
166
|
+
self.settings_file = self.user_data_path / "feature_settings.json"
|
|
167
|
+
self._preferences: Dict[str, bool] = {}
|
|
168
|
+
self._load_preferences()
|
|
169
|
+
|
|
170
|
+
def _load_preferences(self):
|
|
171
|
+
"""Load user feature preferences from disk."""
|
|
172
|
+
if self.settings_file.exists():
|
|
173
|
+
try:
|
|
174
|
+
with open(self.settings_file, "r", encoding="utf-8") as f:
|
|
175
|
+
self._preferences = json.load(f)
|
|
176
|
+
except (json.JSONDecodeError, IOError):
|
|
177
|
+
self._preferences = {}
|
|
178
|
+
|
|
179
|
+
# Apply defaults for any missing features
|
|
180
|
+
for feature_id, feature in FEATURE_MODULES.items():
|
|
181
|
+
if feature_id not in self._preferences:
|
|
182
|
+
self._preferences[feature_id] = feature.enabled_by_default
|
|
183
|
+
|
|
184
|
+
def _save_preferences(self):
|
|
185
|
+
"""Save user feature preferences to disk."""
|
|
186
|
+
self.user_data_path.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
with open(self.settings_file, "w", encoding="utf-8") as f:
|
|
188
|
+
json.dump(self._preferences, f, indent=2)
|
|
189
|
+
|
|
190
|
+
def get_feature(self, feature_id: str) -> Optional[FeatureModule]:
|
|
191
|
+
"""Get a feature module definition by ID."""
|
|
192
|
+
return FEATURE_MODULES.get(feature_id)
|
|
193
|
+
|
|
194
|
+
def get_all_features(self) -> List[FeatureModule]:
|
|
195
|
+
"""Get all feature module definitions."""
|
|
196
|
+
return list(FEATURE_MODULES.values())
|
|
197
|
+
|
|
198
|
+
def get_features_by_category(self) -> Dict[str, List[FeatureModule]]:
|
|
199
|
+
"""Get features grouped by category."""
|
|
200
|
+
categories: Dict[str, List[FeatureModule]] = {}
|
|
201
|
+
for feature in FEATURE_MODULES.values():
|
|
202
|
+
if feature.category not in categories:
|
|
203
|
+
categories[feature.category] = []
|
|
204
|
+
categories[feature.category].append(feature)
|
|
205
|
+
return categories
|
|
206
|
+
|
|
207
|
+
def is_feature_available(self, feature_id: str) -> bool:
|
|
208
|
+
"""Check if a feature's dependencies are installed."""
|
|
209
|
+
feature = FEATURE_MODULES.get(feature_id)
|
|
210
|
+
if not feature:
|
|
211
|
+
return False
|
|
212
|
+
return feature.is_available()
|
|
213
|
+
|
|
214
|
+
def is_feature_enabled(self, feature_id: str) -> bool:
|
|
215
|
+
"""Check if a feature is enabled by user preference."""
|
|
216
|
+
return self._preferences.get(feature_id, True)
|
|
217
|
+
|
|
218
|
+
def is_feature_usable(self, feature_id: str) -> bool:
|
|
219
|
+
"""Check if a feature can be used (available AND enabled)."""
|
|
220
|
+
return self.is_feature_available(feature_id) and self.is_feature_enabled(feature_id)
|
|
221
|
+
|
|
222
|
+
def set_feature_enabled(self, feature_id: str, enabled: bool):
|
|
223
|
+
"""Enable or disable a feature."""
|
|
224
|
+
self._preferences[feature_id] = enabled
|
|
225
|
+
self._save_preferences()
|
|
226
|
+
|
|
227
|
+
def get_total_size_mb(self, only_enabled: bool = False) -> int:
|
|
228
|
+
"""Get total size of installed/enabled features."""
|
|
229
|
+
total = 0
|
|
230
|
+
for feature_id, feature in FEATURE_MODULES.items():
|
|
231
|
+
if only_enabled and not self.is_feature_enabled(feature_id):
|
|
232
|
+
continue
|
|
233
|
+
if feature.is_available():
|
|
234
|
+
total += feature.size_mb
|
|
235
|
+
return total
|
|
236
|
+
|
|
237
|
+
def get_install_command(self, feature_id: str) -> str:
|
|
238
|
+
"""Get the pip install command for a feature."""
|
|
239
|
+
feature = FEATURE_MODULES.get(feature_id)
|
|
240
|
+
if not feature:
|
|
241
|
+
return ""
|
|
242
|
+
return f"pip install supervertaler[{feature.pip_extra}]"
|
|
243
|
+
|
|
244
|
+
def get_missing_features(self) -> List[FeatureModule]:
|
|
245
|
+
"""Get features that are enabled but not installed."""
|
|
246
|
+
missing = []
|
|
247
|
+
for feature_id, feature in FEATURE_MODULES.items():
|
|
248
|
+
if self.is_feature_enabled(feature_id) and not feature.is_available():
|
|
249
|
+
missing.append(feature)
|
|
250
|
+
return missing
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Lazy import helpers - use these instead of direct imports
|
|
254
|
+
def lazy_import_supermemory():
|
|
255
|
+
"""Lazily import Supermemory module."""
|
|
256
|
+
try:
|
|
257
|
+
from modules.supermemory import SupermemoryEngine
|
|
258
|
+
return SupermemoryEngine
|
|
259
|
+
except ImportError:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def lazy_import_whisper():
|
|
264
|
+
"""Lazily import Whisper for voice commands."""
|
|
265
|
+
try:
|
|
266
|
+
import whisper
|
|
267
|
+
return whisper
|
|
268
|
+
except ImportError:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def lazy_import_chromadb():
|
|
273
|
+
"""Lazily import ChromaDB for vector storage."""
|
|
274
|
+
try:
|
|
275
|
+
import chromadb
|
|
276
|
+
return chromadb
|
|
277
|
+
except ImportError:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def lazy_import_sentence_transformers():
|
|
282
|
+
"""Lazily import sentence-transformers for embeddings."""
|
|
283
|
+
try:
|
|
284
|
+
from sentence_transformers import SentenceTransformer
|
|
285
|
+
return SentenceTransformer
|
|
286
|
+
except ImportError:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def lazy_import_deepl():
|
|
291
|
+
"""Lazily import DeepL API client."""
|
|
292
|
+
try:
|
|
293
|
+
import deepl
|
|
294
|
+
return deepl
|
|
295
|
+
except ImportError:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def lazy_import_boto3():
|
|
300
|
+
"""Lazily import boto3 for Amazon Translate."""
|
|
301
|
+
try:
|
|
302
|
+
import boto3
|
|
303
|
+
return boto3
|
|
304
|
+
except ImportError:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def lazy_import_hunspell():
|
|
309
|
+
"""Lazily import Hunspell spellchecker."""
|
|
310
|
+
try:
|
|
311
|
+
import hunspell
|
|
312
|
+
return hunspell
|
|
313
|
+
except ImportError:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def lazy_import_webengine():
|
|
318
|
+
"""Lazily import PyQt6 WebEngine."""
|
|
319
|
+
try:
|
|
320
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
321
|
+
return QWebEngineView
|
|
322
|
+
except ImportError:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def lazy_import_fitz():
|
|
327
|
+
"""Lazily import PyMuPDF (fitz) for PDF processing."""
|
|
328
|
+
try:
|
|
329
|
+
import fitz
|
|
330
|
+
return fitz
|
|
331
|
+
except ImportError:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Global feature manager instance (initialized on first use)
|
|
336
|
+
_feature_manager: Optional[FeatureManager] = None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_feature_manager(user_data_path: str = "user_data") -> FeatureManager:
|
|
340
|
+
"""Get or create the global feature manager instance."""
|
|
341
|
+
global _feature_manager
|
|
342
|
+
if _feature_manager is None:
|
|
343
|
+
_feature_manager = FeatureManager(user_data_path)
|
|
344
|
+
return _feature_manager
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def check_feature(feature_id: str) -> bool:
|
|
348
|
+
"""Quick check if a feature is usable. Use at import time."""
|
|
349
|
+
fm = get_feature_manager()
|
|
350
|
+
return fm.is_feature_usable(feature_id)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Figure Context Manager
|
|
3
|
+
Handles loading, displaying, and providing visual context for technical translations.
|
|
4
|
+
|
|
5
|
+
This module manages figure images that can be automatically included with translation
|
|
6
|
+
requests when the source text references figures (e.g., "Figure 1A", "see fig 2").
|
|
7
|
+
|
|
8
|
+
Author: Michael Beijer + AI Assistant
|
|
9
|
+
Date: October 13, 2025
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Dict, List, Tuple, Any, Optional
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import base64
|
|
16
|
+
import io
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from PIL import Image, ImageTk
|
|
20
|
+
PIL_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
PIL_AVAILABLE = False
|
|
23
|
+
Image = None
|
|
24
|
+
ImageTk = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FigureContextManager:
|
|
28
|
+
"""Manages figure context images for multimodal AI translation."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the Figure Context Manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
app: Reference to main application (for logging and UI updates)
|
|
36
|
+
"""
|
|
37
|
+
self.app = app
|
|
38
|
+
self.figure_context_map: Dict[str, Any] = {} # ref -> PIL Image
|
|
39
|
+
self.figure_context_folder: Optional[str] = None
|
|
40
|
+
self._photo_references = [] # Store PhotoImage references to prevent GC
|
|
41
|
+
|
|
42
|
+
def detect_figure_references(self, text: str) -> List[str]:
|
|
43
|
+
"""
|
|
44
|
+
Detect figure references in text and return normalized list.
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
"As shown in Figure 1A" -> ['1a']
|
|
48
|
+
"See Figures 2 and 3B" -> ['2', '3b']
|
|
49
|
+
"refer to fig. 4" -> ['4']
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: Source text to scan for figure references
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of normalized figure references (lowercase, no spaces)
|
|
56
|
+
"""
|
|
57
|
+
if not text:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
# Pattern matches: figure/figuur/fig + optional period + space + identifier
|
|
61
|
+
# Match: digit(s) optionally followed by letter(s) (e.g., 1, 2A, 3b, 10C)
|
|
62
|
+
pattern = r"(?:figure|figuur|fig\.?)\s+(\d+[a-zA-Z]?)"
|
|
63
|
+
matches = re.findall(pattern, text, re.IGNORECASE)
|
|
64
|
+
|
|
65
|
+
normalized_refs = []
|
|
66
|
+
for match in matches:
|
|
67
|
+
normalized = normalize_figure_ref(f"fig {match}")
|
|
68
|
+
if normalized and normalized not in normalized_refs:
|
|
69
|
+
normalized_refs.append(normalized)
|
|
70
|
+
|
|
71
|
+
return normalized_refs
|
|
72
|
+
|
|
73
|
+
def load_from_folder(self, folder_path: str) -> int:
|
|
74
|
+
"""
|
|
75
|
+
Load all figure images from a folder.
|
|
76
|
+
|
|
77
|
+
Supported formats: .png, .jpg, .jpeg, .gif, .bmp, .tiff
|
|
78
|
+
|
|
79
|
+
Filename examples:
|
|
80
|
+
- "Figure 1.png" -> ref '1'
|
|
81
|
+
- "Figure 2A.jpg" -> ref '2a'
|
|
82
|
+
- "fig3b.png" -> ref '3b'
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
folder_path: Path to folder containing figure images
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Number of successfully loaded images
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
Exception: If folder doesn't exist or PIL is not available
|
|
92
|
+
"""
|
|
93
|
+
if not PIL_AVAILABLE:
|
|
94
|
+
raise Exception("PIL/Pillow library is not installed")
|
|
95
|
+
|
|
96
|
+
if not os.path.exists(folder_path):
|
|
97
|
+
raise Exception(f"Folder not found: {folder_path}")
|
|
98
|
+
|
|
99
|
+
self.figure_context_folder = folder_path
|
|
100
|
+
self.figure_context_map.clear()
|
|
101
|
+
|
|
102
|
+
image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff')
|
|
103
|
+
loaded_count = 0
|
|
104
|
+
|
|
105
|
+
for filename in os.listdir(folder_path):
|
|
106
|
+
if filename.lower().endswith(image_extensions):
|
|
107
|
+
img_path = os.path.join(folder_path, filename)
|
|
108
|
+
try:
|
|
109
|
+
img = Image.open(img_path)
|
|
110
|
+
# Normalize the filename to match figure references
|
|
111
|
+
normalized_name = normalize_figure_ref(filename)
|
|
112
|
+
if normalized_name:
|
|
113
|
+
self.figure_context_map[normalized_name] = img
|
|
114
|
+
loaded_count += 1
|
|
115
|
+
if hasattr(self.app, 'log'):
|
|
116
|
+
self.app.log(f"[Figure Context] Loaded: {filename} as '{normalized_name}'")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
if hasattr(self.app, 'log'):
|
|
119
|
+
self.app.log(f"[Figure Context] Failed to load {filename}: {e}")
|
|
120
|
+
|
|
121
|
+
return loaded_count
|
|
122
|
+
|
|
123
|
+
def clear(self):
|
|
124
|
+
"""Clear all loaded figure context images."""
|
|
125
|
+
self.figure_context_map.clear()
|
|
126
|
+
self.figure_context_folder = None
|
|
127
|
+
self._photo_references.clear()
|
|
128
|
+
|
|
129
|
+
def get_images_for_text(self, text: str) -> List[Tuple[str, Any]]:
|
|
130
|
+
"""
|
|
131
|
+
Get figure images relevant to the given text.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
text: Source text that may contain figure references
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of tuples (ref, PIL.Image) for detected and available figures
|
|
138
|
+
"""
|
|
139
|
+
figure_refs = self.detect_figure_references(text)
|
|
140
|
+
figure_images = []
|
|
141
|
+
|
|
142
|
+
for ref in figure_refs:
|
|
143
|
+
if ref in self.figure_context_map:
|
|
144
|
+
figure_images.append((ref, self.figure_context_map[ref]))
|
|
145
|
+
|
|
146
|
+
return figure_images
|
|
147
|
+
|
|
148
|
+
def has_images(self) -> bool:
|
|
149
|
+
"""Check if any images are loaded."""
|
|
150
|
+
return len(self.figure_context_map) > 0
|
|
151
|
+
|
|
152
|
+
def get_image_count(self) -> int:
|
|
153
|
+
"""Get the number of loaded images."""
|
|
154
|
+
return len(self.figure_context_map)
|
|
155
|
+
|
|
156
|
+
def get_folder_name(self) -> Optional[str]:
|
|
157
|
+
"""Get the basename of the loaded folder, or None."""
|
|
158
|
+
if self.figure_context_folder:
|
|
159
|
+
return os.path.basename(self.figure_context_folder)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def update_ui_display(self, image_folder_label=None, image_folder_var=None,
|
|
163
|
+
thumbnails_frame=None, figure_canvas=None):
|
|
164
|
+
"""
|
|
165
|
+
Update UI elements to reflect current figure context state.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
image_folder_label: tk.Label widget for main Images tab
|
|
169
|
+
image_folder_var: tk.StringVar for Context notebook
|
|
170
|
+
thumbnails_frame: tk.Frame for displaying thumbnails
|
|
171
|
+
figure_canvas: tk.Canvas for scrollable thumbnail area
|
|
172
|
+
"""
|
|
173
|
+
# Update folder label
|
|
174
|
+
if image_folder_label:
|
|
175
|
+
if self.has_images():
|
|
176
|
+
count = self.get_image_count()
|
|
177
|
+
folder_name = self.get_folder_name() or "Unknown"
|
|
178
|
+
image_folder_label.config(
|
|
179
|
+
text=f"✓ {count} image{'s' if count != 1 else ''} loaded from: {folder_name}",
|
|
180
|
+
fg='#4CAF50'
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
image_folder_label.config(
|
|
184
|
+
text="No figure context loaded",
|
|
185
|
+
fg='#999'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Update image_folder_var for Context notebook
|
|
189
|
+
if image_folder_var:
|
|
190
|
+
if self.has_images():
|
|
191
|
+
count = self.get_image_count()
|
|
192
|
+
folder_name = self.get_folder_name() or "Unknown"
|
|
193
|
+
image_folder_var.set(f"✓ {count} figure{'s' if count != 1 else ''} loaded from: {folder_name}")
|
|
194
|
+
else:
|
|
195
|
+
image_folder_var.set("No figure context loaded")
|
|
196
|
+
|
|
197
|
+
# Update thumbnails display
|
|
198
|
+
if thumbnails_frame:
|
|
199
|
+
self._update_thumbnails(thumbnails_frame)
|
|
200
|
+
|
|
201
|
+
def _update_thumbnails(self, thumbnails_frame):
|
|
202
|
+
"""
|
|
203
|
+
Update the thumbnail display in the Images tab.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
thumbnails_frame: tk.Frame to populate with thumbnails
|
|
207
|
+
"""
|
|
208
|
+
import tkinter as tk
|
|
209
|
+
|
|
210
|
+
# Clear existing thumbnails
|
|
211
|
+
for widget in thumbnails_frame.winfo_children():
|
|
212
|
+
widget.destroy()
|
|
213
|
+
|
|
214
|
+
# Clear photo references
|
|
215
|
+
self._photo_references.clear()
|
|
216
|
+
|
|
217
|
+
if self.has_images() and PIL_AVAILABLE:
|
|
218
|
+
# Display thumbnails
|
|
219
|
+
for ref, img in sorted(self.figure_context_map.items()):
|
|
220
|
+
# Create frame for each thumbnail
|
|
221
|
+
thumb_frame = tk.Frame(thumbnails_frame, bg='white', relief='solid', borderwidth=1)
|
|
222
|
+
thumb_frame.pack(fill='x', padx=5, pady=5)
|
|
223
|
+
|
|
224
|
+
# Figure name label
|
|
225
|
+
tk.Label(thumb_frame, text=f"Figure {ref.upper()}",
|
|
226
|
+
font=('Segoe UI', 9, 'bold'), bg='white').pack(anchor='w', padx=5, pady=2)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
# Create thumbnail (max 200px wide)
|
|
230
|
+
img_copy = img.copy()
|
|
231
|
+
img_copy.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
|
232
|
+
photo = ImageTk.PhotoImage(img_copy)
|
|
233
|
+
|
|
234
|
+
# Store reference to prevent garbage collection
|
|
235
|
+
self._photo_references.append(photo)
|
|
236
|
+
|
|
237
|
+
# Display thumbnail
|
|
238
|
+
img_label = tk.Label(thumb_frame, image=photo, bg='white')
|
|
239
|
+
img_label.pack(padx=5, pady=5)
|
|
240
|
+
|
|
241
|
+
# Image info
|
|
242
|
+
info_text = f"{img.width} × {img.height} px"
|
|
243
|
+
tk.Label(thumb_frame, text=info_text,
|
|
244
|
+
font=('Segoe UI', 8), fg='#999', bg='white').pack(anchor='w', padx=5, pady=2)
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
tk.Label(thumb_frame, text=f"Error displaying: {e}",
|
|
248
|
+
font=('Segoe UI', 8), fg='red', bg='white').pack(padx=5, pady=2)
|
|
249
|
+
else:
|
|
250
|
+
# No images loaded - show placeholder
|
|
251
|
+
tk.Label(thumbnails_frame,
|
|
252
|
+
text="No images loaded\n\nUse 'Load figure context...' to add visual context for technical translations.",
|
|
253
|
+
font=('Segoe UI', 9), fg='#999', bg='white',
|
|
254
|
+
justify='center').pack(expand=True, pady=20)
|
|
255
|
+
|
|
256
|
+
def save_state(self) -> Dict[str, Any]:
|
|
257
|
+
"""
|
|
258
|
+
Save current state for project persistence.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Dictionary with folder_path and image_count
|
|
262
|
+
"""
|
|
263
|
+
return {
|
|
264
|
+
'folder_path': self.figure_context_folder,
|
|
265
|
+
'image_count': self.get_image_count()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def restore_state(self, state: Dict[str, Any]) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Restore state from saved project data.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
state: Dictionary with figure context state
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if images were successfully loaded, False otherwise
|
|
277
|
+
"""
|
|
278
|
+
folder_path = state.get('folder_path')
|
|
279
|
+
if folder_path and os.path.exists(folder_path):
|
|
280
|
+
try:
|
|
281
|
+
loaded_count = self.load_from_folder(folder_path)
|
|
282
|
+
if loaded_count > 0:
|
|
283
|
+
if hasattr(self.app, 'log'):
|
|
284
|
+
self.app.log(f"✓ Loaded figure context: {loaded_count} images from {os.path.basename(folder_path)}")
|
|
285
|
+
return True
|
|
286
|
+
except Exception as e:
|
|
287
|
+
if hasattr(self.app, 'log'):
|
|
288
|
+
self.app.log(f"⚠ Failed to restore figure context: {e}")
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# === Helper Functions ===
|
|
293
|
+
|
|
294
|
+
def normalize_figure_ref(text: str) -> Optional[str]:
|
|
295
|
+
"""
|
|
296
|
+
Normalize a figure reference to a standard format.
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
"Figure 1" -> "1"
|
|
300
|
+
"fig. 2A" -> "2a"
|
|
301
|
+
"Figure3-B.png" -> "3b"
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
text: Text containing figure reference or filename
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Normalized reference (lowercase, alphanumeric only) or None
|
|
308
|
+
"""
|
|
309
|
+
if not text:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
# Remove common prefixes and file extensions
|
|
313
|
+
text = text.lower()
|
|
314
|
+
text = re.sub(r'\.(png|jpg|jpeg|gif|bmp|tiff)$', '', text)
|
|
315
|
+
|
|
316
|
+
# Extract figure reference (letters and numbers)
|
|
317
|
+
match = re.search(r'(?:figure|figuur|fig\.?)\s*([\w\d]+(?:[\s\.\-]*[\w\d]+)?)', text, re.IGNORECASE)
|
|
318
|
+
if match:
|
|
319
|
+
ref = match.group(1)
|
|
320
|
+
# Remove spaces, dots, dashes
|
|
321
|
+
ref = re.sub(r'[\s\.\-]', '', ref)
|
|
322
|
+
return ref.lower()
|
|
323
|
+
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def pil_image_to_base64_png(img: Any) -> str:
|
|
328
|
+
"""
|
|
329
|
+
Convert PIL Image to base64-encoded PNG string.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
img: PIL.Image object
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Base64-encoded PNG data string
|
|
336
|
+
"""
|
|
337
|
+
buffer = io.BytesIO()
|
|
338
|
+
img.save(buffer, format='PNG')
|
|
339
|
+
buffer.seek(0)
|
|
340
|
+
return base64.b64encode(buffer.read()).decode('utf-8')
|