tugaa commited on
Commit
7cf1567
·
verified ·
1 Parent(s): d216644

Update mainapp.py

Browse files
Files changed (1) hide show
  1. mainapp.py +742 -287
mainapp.py CHANGED
@@ -1,309 +1,764 @@
1
  import os
2
  import re
3
- import argparse
4
- import configparser
5
- from janome.tokenizer import Tokenizer
6
- import jaconv
7
- from typing import List, Optional, Dict
8
  import logging
9
  import json
 
 
 
 
 
10
  import tkinter as tk
11
- from tkinter import filedialog, messagebox
12
-
13
- # ロギングの設定
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
15
  logger = logging.getLogger(__name__)
16
 
17
- class TextProcessingError(Exception):
18
- """テキスト処理に関するカスタム例外クラス"""
19
- pass
20
-
21
- class FileProcessingError(TextProcessingError):
22
- """ファイル処理に関するカスタム例外クラス"""
23
- pass
24
-
25
- class InvalidOutputFormatError(TextProcessingError):
26
- """無効な出力フォーマットに関するカスタム例外クラス"""
27
- pass
28
-
29
- class TextProcessor:
30
- def __init__(self, udic_path: Optional[str] = None, remove_symbols: bool = True, convert_kanji: bool = False):
31
- """
32
- TextProcessorクラスの初期化
33
-
34
- Args:
35
- udic_path (Optional[str]): ユーザー辞書のパス。デフォルトはNone。
36
- remove_symbols (bool): 記号を削除するかどうか。デフォルトはTrue。
37
- convert_kanji (bool): 漢字をひらがなに変換するかどうか。デフォルトはFalse
38
- """
39
- self.tokenizer = Tokenizer(udic=udic_path) if udic_path else Tokenizer()
40
- self.remove_symbols = remove_symbols
41
- self.convert_kanji = convert_kanji
42
- logger.info(f"TextProcessor initialized with udic_path: {udic_path}, remove_symbols: {remove_symbols}, convert_kanji: {convert_kanji}")
43
-
44
- def extract_hiragana(self, text: str) -> str:
45
- """
46
- テキストデータからひらがなを抽出します。
47
-
48
- Args:
49
- text (str): 処理するテキスト。
50
-
51
- Returns:
52
- str: ひらがな抽出後のテキスト。
53
- """
54
- hiragana_words: List[str] = []
55
- for token in self.tokenizer.tokenize(text):
56
- if token.reading != '*':
57
- # 読みが存在する場合は、ひらがなに変換
58
- reading = jaconv.h2z(token.reading, kana=True, ascii=False, digit=False)
59
- hiragana_words.append(jaconv.kata2hira(reading))
60
- elif all('\u3041' <= char <= '\u3096' for char in token.surface):
61
- # ひらがなの場合はそのまま追加
62
- hiragana_words.append(token.surface)
63
- elif all('\u4e00' <= char <= '\u9fff' for char in token.surface) and self.convert_kanji:
64
- # 漢字のみの場合は、変換を試みる
65
- # (ここに漢字をひらがなに変換するロジックを追加)
66
- # 今のところ、漢字はそのまま追加
67
- hiragana_words.append(token.surface)
68
- elif any('\u3041' <= char <= '\u3096' for char in token.surface) or any('\u4e00' <= char <= '\u9fff' for char in token.surface):
69
- # ひらがなと漢字が混ざっている場合は、可能な限りひらがな化
70
- mixed_word = ""
71
- for char in token.surface:
72
- if '\u3041' <= char <= '\u3096':
73
- mixed_word += char
74
- elif '\u4e00' <= char <= '\u9fff' and self.convert_kanji:
75
- mixed_word += char
76
- hiragana_words.append(mixed_word)
77
- elif token.part_of_speech.startswith('記号') and not self.remove_symbols:
78
- # 記号を削除しない設定の場合
79
- hiragana_words.append(token.surface)
80
-
81
- return "".join(hiragana_words)
82
-
83
- def preprocess_line(self, text: str, to_lower: bool = False, remove_punctuation: bool = False, remove_numbers: bool = False) -> str:
84
- """
85
- テキストデータの前処理を行います(行ごと処理)。
86
-
87
- Args:
88
- text (str): 処理するテキスト。
89
- to_lower (bool): 小文字に変換するかどうか。デフォルトはFalse。
90
- remove_punctuation (bool): 句読点を削除するかどうか。デフォルトはFalse。
91
- remove_numbers (bool): 数字を削除するかどうか。デフォルトはFalse。
92
-
93
- Returns:
94
- str: 前処理後のテキスト。
95
- """
96
- text = text.replace('\r\n', '\n').replace('\r', '\n').strip()
97
- text = re.sub(r'\s+', ' ', text)
98
-
99
- if to_lower:
100
- text = text.lower()
101
-
102
- if remove_punctuation:
103
- text = re.sub(r'[!"#$%&\'()*+,-./:;<=>?@\[\\\]^_`{|}~]', '', text)
104
-
105
- if remove_numbers:
106
- text = re.sub(r'[0-9]', '', text)
107
-
108
- katakana_words = []
109
- for token in self.tokenizer.tokenize(text):
110
- if token.reading == '*':
111
- katakana_words.append(token.surface)
112
- else:
113
- katakana_words.append(token.reading)
114
-
115
- return "".join(katakana_words)
116
-
117
- def read_text_with_bom_removal(filepath: str, encoding: str = 'utf-8') -> str:
118
- """
119
- BOM付きの可能性のあるテキストファイルを読み込み、BOMを取り除いて返します。
120
-
121
- Args:
122
- filepath (str): ファイルパス。
123
- encoding (str): ファイルのエンコーディング。デフォルトは'utf-8'。
124
-
125
- Returns:
126
- str: BOMを取り除いたテキストデータ。
127
-
128
- Raises:
129
- FileProcessingError: ファイルの読み込みに失敗した場合。
130
- """
131
- try:
132
- with open(filepath, 'rb') as f:
133
- raw_data = f.read()
134
-
135
- if raw_data.startswith(b'\xef\xbb\xbf'):
136
- return raw_data[3:].decode(encoding)
137
- else:
138
- return raw_data.decode(encoding)
139
- except FileNotFoundError:
140
- raise FileProcessingError(f"ファイル '{filepath}' が見つかりません。")
141
- except UnicodeDecodeError as e:
142
- raise FileProcessingError(f"ファイル '{filepath}' のデコードに失敗しました: {e}")
143
- except Exception as e:
144
- raise FileProcessingError(f"ファイル '{filepath}' の読み込み中に予期しないエラーが発生しました: {e}")
145
-
146
- def output_comparison_data(filename: str, original_text: str, preprocessed_text: str, hiragana_text: str, output_folder: str, output_format: str = 'tsv') -> None:
147
- """
148
- 元のテキスト、カタカナ変換後のテキスト、ひらがな抽出後のテキストを行ごとに指定されたフォーマットでファイルに出力します。
149
- TSVとJSONLの両方を出力するように変更。
150
- """
151
- if not os.path.exists(output_folder):
152
- # ディレクトリがない場合、作成するか確認
153
- if messagebox.askyesno("ディレクトリ作成", f"出力ディレクトリ '{output_folder}' が存在しません。作成しますか?"):
154
  try:
155
- os.makedirs(output_folder)
 
 
 
 
 
 
 
 
 
156
  except Exception as e:
157
- raise FileProcessingError(f"出力ディレクトリ '{output_folder}' の作成に失敗しました: {e}")
 
158
  else:
159
- raise FileProcessingError(f"出力ディレクトリ '{output_folder}' が存在しないため、処理を中止します。")
160
-
161
- base_filename, ext = os.path.splitext(filename)
162
- output_tsv_filepath = os.path.join(output_folder, f"{base_filename}_comparison.tsv")
163
- output_jsonl_filepath = os.path.join(output_folder, f"{base_filename}_comparison.jsonl")
164
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  try:
166
- with open(output_tsv_filepath, 'w', encoding='utf-8', errors='replace') as tsvfile, \
167
- open(output_jsonl_filepath, 'w', encoding='utf-8', errors='replace') as jsonlfile:
168
-
169
- tsvfile.write("Original\tKatakana\tHiragana\n")
170
- original_lines = original_text.splitlines()
171
- preprocessed_lines = preprocessed_text.splitlines()
172
- hiragana_lines = hiragana_text.splitlines()
173
-
174
- max_lines = max(len(original_lines), len(preprocessed_lines), len(hiragana_lines))
175
- for i in range(max_lines):
176
- original = original_lines[i] if i < len(original_lines) else ""
177
- preprocessed = preprocessed_lines[i] if i < len(preprocessed_lines) else ""
178
- hiragana = hiragana_lines[i] if i < len(hiragana_lines) else ""
179
-
180
- # TSVファイルへの書き込み
181
- tsvfile.write(f"{original}\t{preprocessed}\t{hiragana}\n")
182
-
183
- # JSONLファイルへの書き込み
184
- json_data = {
185
- "Original": original,
186
- "Katakana": preprocessed,
187
- "Hiragana": hiragana
188
- }
189
- jsonlfile.write(json.dumps(json_data, ensure_ascii=False) + '\n')
190
-
191
- logger.info(f"比較データを '{output_tsv_filepath}' に出力しました。")
192
- logger.info(f"比較データを '{output_jsonl_filepath}' に出力しました。")
193
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  except Exception as e:
195
- raise FileProcessingError(f"ファイルへの書き込みに失敗しました: {e}")
196
-
197
- def process_file(filepath: str, output_folder: str, text_processor: TextProcessor, output_format: str) -> None:
198
- """
199
- 指定されたテキストファイルを読み込み、行ごとに前処理(カタカナ変換)とひらがな抽出を行い、比較データを出力します。
200
 
201
- Args:
202
- filepath (str): 処理するファイルのパス。
203
- output_folder (str): 出力フォルダのパス。
204
- text_processor (TextProcessor): テキスト処理を行うTextProcessorオブジェクト。
205
- output_format (str): 出力フォーマット('tsv'または'csv')。
206
 
207
- Raises:
208
- FileProcessingError: ファイルの読み込みまたは処理中にエラーが発生した場合。
209
- """
 
 
 
 
210
  filename = os.path.basename(filepath)
 
 
 
 
 
 
 
211
  try:
212
- original_text = read_text_with_bom_removal(filepath)
213
- original_lines = original_text.splitlines()
214
- preprocessed_lines: List[str] = []
215
- hiragana_lines: List[str] = []
216
-
217
- for line in original_lines:
218
- if line.strip():
219
- preprocessed_line = text_processor.preprocess_line(line)
220
- hiragana_line = text_processor.extract_hiragana(line)
221
- preprocessed_lines.append(preprocessed_line)
222
- hiragana_lines.append(hiragana_line)
223
- else:
224
- preprocessed_lines.append("") # 空行を保持
225
- hiragana_lines.append("") # 空行を保持
226
 
227
- output_comparison_data(filename, original_text, "\n".join(preprocessed_lines), "\n".join(hiragana_lines), output_folder, output_format)
 
 
 
228
 
229
- except FileProcessingError as e:
230
- raise e
231
- except Exception as e:
232
- raise FileProcessingError(f"ファイル '{filepath}' の処理中にエラーが発生しました: {e}")
233
-
234
- def select_files():
235
- """ファイルを選択するダイアログを表示します。"""
236
- root = tk.Tk()
237
- root.withdraw() # メインウィンドウを非表示
238
- file_paths = filedialog.askopenfilenames(title="処理するテキストファイルを選択してください", filetypes=[("Text files", "*.txt")])
239
- return list(file_paths)
240
-
241
- def select_output_folder():
242
- """出力フォルダを選択するダイアログを表示します。"""
243
- root = tk.Tk()
244
- root.withdraw() # メインウィンドウを非表示
245
- folder_path = filedialog.askdirectory(title="出���フォルダを選択してください")
246
- return folder_path
247
-
248
- def main():
249
- """
250
- メイン関数。テキストファイルの前処理とひらがな抽出を行います。
251
- """
252
- parser = argparse.ArgumentParser(description='テキストファイルの前処理とひらがな抽出を行います。')
253
- parser.add_argument('--config', type=str, help='設定ファイルのパス')
254
- parser.add_argument('--udic_path', type=str, help='udicのパス')
255
- parser.add_argument('--file_extension', type=str, default='.txt', help='処理するファイルの拡張子')
256
- parser.add_argument('--output_format', type=str, default='tsv', choices=['tsv', 'csv'], help='出力フォーマット(tsvまたはcsv)')
257
- parser.add_argument('--remove_symbols', action='store_true', help='記号を削除する')
258
- parser.add_argument('--convert_kanji', action='store_true', help='漢字をひらがなに変換する')
259
- args = parser.parse_args()
260
-
261
- config = configparser.ConfigParser()
262
- if args.config and os.path.exists(args.config):
263
- config.read(args.config)
264
- output_folder = config.get('Paths', 'output_folder', fallback=None)
265
- udic_path = config.get('Paths', 'udic_path', fallback=args.udic_path)
266
- file_extension = config.get('Settings', 'file_extension', fallback=args.file_extension)
267
- output_format = config.get('Settings', 'output_format', fallback=args.output_format)
268
- remove_symbols = config.getboolean('Settings', 'remove_symbols', fallback=not args.remove_symbols)
269
- convert_kanji = config.getboolean('Settings', 'convert_kanji', fallback=args.convert_kanji)
270
- else:
271
- output_folder = None
272
- udic_path = args.udic_path
273
- file_extension = args.file_extension
274
- output_format = args.output_format
275
- remove_symbols = not args.remove_symbols
276
- convert_kanji = args.convert_kanji
277
-
278
- # ファイル選択ダイアログを表示
279
- file_paths = select_files()
280
- if not file_paths:
281
- logger.info("ファイルが選択されなかったため、処理を中止します。")
282
- return
283
-
284
- # 出力フォルダ選択ダイアログを表示
285
- if output_folder is None:
286
- output_folder = select_output_folder()
287
- if not output_folder:
288
- logger.info("出力フォルダが選択されなかったため、処理を中止します。")
289
- return
290
-
291
- # パスの正規化
292
- output_folder = os.path.normpath(output_folder)
293
-
294
- text_processor = TextProcessor(udic_path, remove_symbols, convert_kanji)
295
 
296
- try:
297
- total_files = len(file_paths)
298
- for i, filepath in enumerate(file_paths):
299
- logger.info(f"処理中のファイル ({i+1}/{total_files}): {filepath}")
300
  try:
301
- process_file(filepath, output_folder, text_processor, output_format)
302
- except FileProcessingError as e:
303
- logger.error(f"ファイル '{filepath}' の処理中にエラーが発生しました: {e}")
304
- logger.info("すべてのファイルの処理が完了しました。")
305
- except FileProcessingError as e:
306
- logger.error(f"ファイルの処理中にエラーが発生しました: {e}")
307
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  if __name__ == "__main__":
309
- main()
 
 
 
 
 
 
 
1
  import os
2
  import re
3
+ import unicodedata
4
+ from typing import List, Optional, Dict, Any, Tuple, Literal
 
 
 
5
  import logging
6
  import json
7
+ import sys
8
+ import csv
9
+ import yaml # PyYAMLが必要: pip install PyYAML
10
+ import jaconv # jaconvが必要: pip install jaconv
11
+ import threading
12
  import tkinter as tk
13
+ from tkinter import ttk, filedialog, messagebox, scrolledtext
14
+
15
+ # --- 依存ライブラリのチェックとインポート ---
16
+ try:
17
+ from janome.tokenizer import Tokenizer, Token
18
+ from janome.analyzer import Analyzer
19
+ from janome.charfilter import UnicodeNormalizeCharFilter
20
+ # from janome.tokenfilter import POSKeepFilter, CompoundNounFilter # 必要なら使う
21
+ JANOME_AVAILABLE = True
22
+ except ImportError:
23
+ JANOME_AVAILABLE = False
24
+
25
+ try:
26
+ import pyopenjtalk
27
+ PYOPENJTALK_AVAILABLE = True
28
+ except ImportError:
29
+ PYOPENJTALK_AVAILABLE = False
30
+ except Exception as e: # pyopenjtalkはImportError以外も発生しうる
31
+ PYOPENJTALK_AVAILABLE = False
32
+ print(f"警告: pyopenjtalkのインポート中にエラーが発生しました: {e}", file=sys.stderr)
33
+
34
+
35
+ # --- GUI機能が利用可能か ---
36
+ # ENABLE_GUI = True # tkinterのimport成功時にTrueになる想定
37
+ try:
38
+ # tkinterは標準ライブラリだが、最小環境でない場合もある
39
+ import tkinter as tk
40
+ from tkinter import ttk, filedialog, messagebox, scrolledtext
41
+ ENABLE_GUI = True
42
+ except ImportError:
43
+ ENABLE_GUI = False
44
+
45
+ # ロギングの設定 (GUI表示用にレベルはINFO推奨)
46
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
47
  logger = logging.getLogger(__name__)
48
 
49
+ # --- 例外クラス ---
50
+ class TextProcessingError(Exception): pass
51
+ class FileProcessingError(TextProcessingError): pass
52
+ class InitializationError(TextProcessingError): pass
53
+ class NormalizationError(TextProcessingError): pass
54
+
55
+ # --- テキスト正規化クラス ---
56
+ class TextNormalizer:
57
+ def __init__(self, rule_filepath: Optional[str] = None):
58
+ self.rules = self._load_rules(rule_filepath)
59
+ logger.info(f"正規化ルールをロードしました (ファイル: {rule_filepath or 'デフォルト'})")
60
+ self.number_converter = self._create_number_converter()
61
+
62
+ def _load_rules(self, filepath: Optional[str]) -> Dict[str, Any]:
63
+ # デフォルトルール (必要最低限)
64
+ default_rules = {
65
+ 'unicode_normalize': 'NFKC',
66
+ 'remove_whitespace': True,
67
+ 'replace_symbols': {'&': ' アンド ', '%': ' パーセント '},
68
+ 'remove_symbols': r'[「」『』【】[]()<>‘’“”・※→←↑↓*#〜]',
69
+ 'punctuation': {'remove': False, 'replacement': ' <pau> ', 'target': '、。?!'},
70
+ 'number_conversion': {'enabled': True, 'target': r'\d+([\.,]\d+)?'},
71
+ 'alphabet_conversion': {'enabled': True, 'rule': 'spellout', 'dictionary': {'AI': 'エーアイ', 'USB': 'ユーエスビー'}},
72
+ 'custom_replacements_pre': [],
73
+ 'custom_replacements_post': [],
74
+ }
75
+ if filepath and os.path.exists(filepath):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  try:
77
+ with open(filepath, 'r', encoding='utf-8') as f:
78
+ loaded_rules = yaml.safe_load(f)
79
+ # より安全なマージ (ネストした辞書も考慮するなら再帰的なマージが必要)
80
+ for key, value in loaded_rules.items():
81
+ if isinstance(value, dict) and isinstance(default_rules.get(key), dict):
82
+ default_rules[key].update(value)
83
+ else:
84
+ default_rules[key] = value
85
+ logger.info(f"カスタム正規化ルール '{filepath}' をロードしました。")
86
+ return default_rules
87
  except Exception as e:
88
+ logger.warning(f"正規化ルールファイル '{filepath}' の読み込みに失敗: {e}。デフォルトルールを使用。")
89
+ return default_rules
90
  else:
91
+ if filepath: logger.warning(f"正規化ルールファイル '{filepath}' が見つかりません。デフォルトルールを使用。")
92
+ else: logger.info("正規化ルールファイル未指定。デフォルトルールを使用。")
93
+ return default_rules
94
+
95
+ def _create_number_converter(self):
96
+ # 数字 -> 日本語読み 変換クラス (要実装)
97
+ class SimpleNumConverter:
98
+ def convert(self, num_str: str) -> str:
99
+ # ここに詳細な変換ロジックを実装する
100
+ # 例: 123 -> ひゃくにじゅうさん, 10.5 -> じゅってんご
101
+ return num_str # プレースホルダー
102
+ return SimpleNumConverter()
103
+
104
+ def normalize(self, text: str) -> str:
105
+ if not isinstance(text, str): return ""
106
+ try:
107
+ # 1. Unicode正規化
108
+ norm_type = self.rules.get('unicode_normalize')
109
+ if norm_type: text = unicodedata.normalize(norm_type, text)
110
+
111
+ # 2. 前処理カスタム置換
112
+ for rule in self.rules.get('custom_replacements_pre', []):
113
+ text = re.sub(rule['pattern'], rule['replacement'], text)
114
+
115
+ # 3. 空白正規化 (早期に行う方が後続処理が楽な場合も)
116
+ if self.rules.get('remove_whitespace', True):
117
+ text = re.sub(r'\s+', ' ', text).strip()
118
+
119
+ # 4. 数字変換 (ルールの target に基づいて検索・置換)
120
+ num_conf = self.rules.get('number_conversion', {})
121
+ if num_conf.get('enabled'):
122
+ pattern = num_conf.get('target', r'\d+([\.,]\d+)?')
123
+ try:
124
+ text = re.sub(pattern, lambda m: self.number_converter.convert(m.group(0)), text)
125
+ except Exception as e_num: logger.warning(f"数字変換エラー: {e_num}")
126
+
127
+ # 5. アルファベット変換 (辞書 + ルール)
128
+ alpha_conf = self.rules.get('alphabet_conversion', {})
129
+ if alpha_conf.get('enabled'):
130
+ for word, reading in alpha_conf.get('dictionary', {}).items():
131
+ # 大文字小文字区別など考慮
132
+ text = text.replace(word, f' {reading} ')
133
+ if alpha_conf.get('rule') == 'spellout':
134
+ # ここにアルファベットを1文字ずつ読む処理 (A->エー) を実装
135
+ pass
136
+
137
+ # 6. 記号読み置換
138
+ for symbol, reading in self.rules.get('replace_symbols', {}).items():
139
+ text = text.replace(symbol, reading)
140
+
141
+ # 7. 記号削除
142
+ remove_pattern = self.rules.get('remove_symbols')
143
+ if remove_pattern:
144
+ text = re.sub(remove_pattern, '', text)
145
+
146
+ # 8. 句読点処理
147
+ punct_conf = self.rules.get('punctuation', {})
148
+ target_punct = punct_conf.get('target', '、。?!')
149
+ punct_pattern = f'[{re.escape(target_punct)}]'
150
+ if punct_conf.get('remove', True):
151
+ text = re.sub(punct_pattern, '', text)
152
+ else:
153
+ replacement = punct_conf.get('replacement', '<pau>')
154
+ text = re.sub(punct_pattern, replacement, text)
155
+
156
+ # 9. 後処理カスタム置換
157
+ for rule in self.rules.get('custom_replacements_post', []):
158
+ text = re.sub(rule['pattern'], rule['replacement'], text)
159
+
160
+ # 10. 最終空白処理
161
+ if self.rules.get('remove_whitespace', True):
162
+ text = re.sub(r'\s+', ' ', text).strip()
163
+
164
+ return text
165
+ except Exception as e:
166
+ raise NormalizationError(f"正規化失敗 ({text[:20]}...): {e}")
167
+
168
+
169
+ # --- 読み・アクセント推定クラス ---
170
+ class PronunciationEstimator:
171
+ def __init__(
172
+ self,
173
+ engine: Literal['janome', 'pyopenjtalk'] = 'pyopenjtalk',
174
+ janome_udic_path: Optional[str] = None,
175
+ jtalk_dic_path: Optional[str] = None,
176
+ jtalk_user_dic_path: Optional[str] = None
177
+ ):
178
+ self.engine = engine
179
+ self.jtalk_user_dic_path = jtalk_user_dic_path # ユーザー辞書パスを保持
180
+ self.unknown_words = set()
181
+ self.janome_analyzer = None # Janomeインスタンス用
182
+ self.jtalk_opts = [] # pyopenjtalkオプション用
183
+
184
+ if self.engine == 'janome':
185
+ if not JANOME_AVAILABLE: raise InitializationError("Janome利用不可")
186
+ try:
187
+ logger.info(f"Janome初期化 (ユーザー辞書: {janome_udic_path})")
188
+ char_filters = [UnicodeNormalizeCharFilter()]
189
+ # ユーザー辞書はTokenizerに渡す
190
+ tokenizer_kwargs = {"udic": janome_udic_path, "udic_enc": "utf8"} if janome_udic_path else {}
191
+ self.janome_analyzer = Analyzer(
192
+ char_filters=char_filters,
193
+ tokenizer=Tokenizer(**tokenizer_kwargs),
194
+ token_filters=[] # 必要なら追加
195
+ )
196
+ logger.info("Janome初期化完了")
197
+ except Exception as e: raise InitializationError(f"Janome初期化失敗: {e}")
198
+
199
+ elif self.engine == 'pyopenjtalk':
200
+ if not PYOPENJTALK_AVAILABLE: raise InitializationError("pyopenjtalk利用不可")
201
+ try:
202
+ logger.info("pyopenjtalk初期化...")
203
+ if jtalk_dic_path:
204
+ logger.info(f"jtalkシステム辞書パス設定: {jtalk_dic_path}")
205
+ os.environ['PYOPENJTALK_DICT_PATH'] = jtalk_dic_path
206
+ # pyopenjtalkのユーザー辞書指定はrun_frontendでは直接できないため注意喚起
207
+ if self.jtalk_user_dic_path:
208
+ logger.warning(f"jtalkユーザー辞書 '{self.jtalk_user_dic_path}' はrun_frontendでは直接使用されません。システム辞書への統合等を検討してください。g2pには影響する可能性があります。")
209
+ # g2p に渡すオプションなど、より詳細な制御が必要なら実装
210
+
211
+ _ = pyopenjtalk.g2p('テスト') # 動作確認
212
+ logger.info("pyopenjtalk初期化完了")
213
+ except Exception as e: raise InitializationError(f"pyopenjtalk初期化失敗: {e}")
214
+ else:
215
+ raise InitializationError(f"無効なエンジン: {self.engine}")
216
+
217
+ def _get_janome_reading(self, text: str) -> Tuple[str, List[Dict[str, Any]]]:
218
+ tokens_details = []
219
+ katakana_parts = []
220
+ try:
221
+ tokens = self.janome_analyzer.analyze(text)
222
+ for token in tokens:
223
+ surf = token.surface
224
+ pos = token.part_of_speech # 詳細な品詞
225
+ reading = token.reading if token.reading != '*' else None
226
+ pron = token.pronunciation if hasattr(token, 'pronunciation') and token.pronunciation != '*' else reading
227
+
228
+ reading_used = reading or pron # 読み or 発音
229
+ if reading_used:
230
+ katakana_parts.append(reading_used)
231
+ else:
232
+ katakana_parts.append(jaconv.hira2kata(surf)) # 表層形をカタカナ化
233
+ if not re.fullmatch(r'[ぁ-んァ-ンー\s]+', surf) and '記号' not in pos:
234
+ self.unknown_words.add(surf)
235
+
236
+ tokens_details.append({
237
+ "surface": surf, "pos": pos, "reading": reading, "pronunciation": pron
238
+ })
239
+ return "".join(katakana_parts), tokens_details
240
+ except Exception as e:
241
+ logger.error(f"Janome読み推定エラー: {e}", exc_info=True)
242
+ return "[READING ERROR]", []
243
+
244
+ def _get_jtalk_pronunciation(self, text: str) -> Tuple[str, List[Dict[str, Any]], List[str]]:
245
+ katakana_reading = "[READING ERROR]"
246
+ tokens_details = []
247
+ full_context_labels = []
248
+ try:
249
+ # 読みはg2pで取得するのが確実
250
+ # ユーザー辞書はg2pには影響するはず (-u オプション?) 要調査
251
+ g2p_opts = ['-u', self.jtalk_user_dic_path] if self.jtalk_user_dic_path and os.path.exists(self.jtalk_user_dic_path) else []
252
+ # katakana_reading = pyopenjtalk.g2p(text, kana=True, opts=g2p_opts) # opts引数は存在しない?
253
+ katakana_reading = pyopenjtalk.g2p(text, kana=True) # 現状 opts なしで実行
254
+
255
+ # LABデータはrun_frontendで取得
256
+ # run_frontendにはユーザー辞書オプションがない
257
+ full_context_labels = pyopenjtalk.run_frontend(text, self.jtalk_opts)
258
+
259
+ # 簡易的なトークン情報(本来はMeCabの結果をパースすべき)
260
+ tokens_details = [{"surface": text, "reading": katakana_reading}]
261
+
262
+ # 未知語判定(読みが表層形と同じでカタカナのみでない場合)
263
+ if katakana_reading == text and not re.fullmatch(r'[ァ-ンヴー\s]+', text):
264
+ self.unknown_words.add(text)
265
+
266
+ return katakana_reading, tokens_details, full_context_labels
267
+ except Exception as e:
268
+ logger.error(f"pyopenjtalk読み推定エラー: {e}", exc_info=True)
269
+ return "[READING ERROR]", [], []
270
+
271
+ def get_pronunciation(self, text: str, output_format: Literal['hiragana', 'katakana'] = 'katakana') -> Tuple[str, List[Dict[str, Any]], List[str]]:
272
+ reading = "[READING ERROR]"
273
+ tokens_details = []
274
+ lab_data = []
275
+
276
+ if not text: return "", [], []
277
+
278
+ if self.engine == 'janome':
279
+ reading, tokens_details = self._get_janome_reading(text)
280
+ elif self.engine == 'pyopenjtalk':
281
+ reading, tokens_details, lab_data = self._get_jtalk_pronunciation(text)
282
+
283
+ # 出力形式変換
284
+ if output_format == 'hiragana' and "[ERROR]" not in reading:
285
+ try: reading = jaconv.kata2hira(reading)
286
+ except Exception: logger.warning("ひらがな変換失敗", exc_info=True)
287
+
288
+ return reading, tokens_details, lab_data
289
+
290
+
291
+ # --- ファイルIO & 出力関数 ---
292
+ def read_text_with_bom_removal(filepath: str, encoding: str = 'utf-8') -> List[str]:
293
  try:
294
+ with open(filepath, 'rb') as f: raw_data = f.read()
295
+ if raw_data.startswith(b'\xef\xbb\xbf'): text = raw_data[3:].decode(encoding, errors='replace')
296
+ elif raw_data.startswith((b'\xff\xfe', b'\xfe\xff')): text = raw_data.decode('utf-16', errors='replace')
297
+ else: text = raw_data.decode(encoding, errors='replace')
298
+ return text.splitlines()
299
+ except FileNotFoundError: raise FileProcessingError(f"ファイルが見つかりません: {filepath}")
300
+ except Exception as e: raise FileProcessingError(f"ファイル読み込みエラー ({filepath}): {e}")
301
+
302
+ def output_corpus_data(output_filepath_base: str, data: List[Dict[str, Any]], output_formats: List[Literal['tsv', 'jsonl']]) -> None:
303
+ output_successful = []
304
+ if not data: logger.warning(f"{output_filepath_base}*: 出力データなし"); return
305
+
306
+ # TSV Output
307
+ if 'tsv' in output_formats:
308
+ fpath = f"{output_filepath_base}.tsv"
309
+ try:
310
+ header = ["id", "text", "reading"] # 基本
311
+ # 必要なら他のキーも追加
312
+ with open(fpath, 'w', encoding='utf-8', newline='') as f:
313
+ writer = csv.DictWriter(f, fieldnames=header, delimiter='\t', quoting=csv.QUOTE_MINIMAL, extrasaction='ignore')
314
+ writer.writeheader()
315
+ writer.writerows(data)
316
+ logger.info(f"TSV出力完了: {fpath}")
317
+ output_successful.append('tsv')
318
+ except Exception as e: logger.error(f"TSV出力失敗 ({fpath}): {e}")
319
+
320
+ # JSONL Output
321
+ if 'jsonl' in output_formats:
322
+ fpath = f"{output_filepath_base}.jsonl"
323
+ try:
324
+ with open(fpath, 'w', encoding='utf-8') as f:
325
+ for item in data:
326
+ item_copy = {k: v for k, v in item.items() if k != 'lab_data'} # LABは除く
327
+ f.write(json.dumps(item_copy, ensure_ascii=False) + '\n')
328
+ logger.info(f"JSONL出力完了: {fpath}")
329
+ output_successful.append('jsonl')
330
+ except Exception as e: logger.error(f"JSONL出力失敗 ({fpath}): {e}")
331
+
332
+ if not output_successful and output_formats:
333
+ raise FileProcessingError("指定形式で���出力に成功せず")
334
+
335
+ def output_lab_data(output_filepath_base: str, all_lab_data: Dict[str, List[str]]) -> None:
336
+ lab_dir = f"{output_filepath_base}_lab"
337
+ try:
338
+ os.makedirs(lab_dir, exist_ok=True)
339
+ count = 0
340
+ for file_id, lab_lines in all_lab_data.items():
341
+ if lab_lines:
342
+ fpath = os.path.join(lab_dir, f"{file_id}.lab")
343
+ with open(fpath, 'w', encoding='utf-8') as f:
344
+ f.write("\n".join(lab_lines))
345
+ count += 1
346
+ logger.info(f"LABデータ出力完了: {count}ファイル -> {lab_dir}")
347
+ except Exception as e: logger.error(f"LABデータ出力失敗: {e}")
348
+
349
+ def output_comparison_data(output_filepath_base: str, comparison_data: List[Dict[str, str]]) -> None:
350
+ """オプション: 元テキスト、正規化テキスト、読みの比較データをTSVで出力"""
351
+ fpath = f"{output_filepath_base}_comparison.tsv"
352
+ try:
353
+ if not comparison_data: return
354
+ header = ["id", "original", "normalized", "reading"]
355
+ with open(fpath, 'w', encoding='utf-8', errors='replace', newline='') as tsvfile:
356
+ writer = csv.DictWriter(tsvfile, fieldnames=header, delimiter='\t', quoting=csv.QUOTE_MINIMAL)
357
+ writer.writeheader()
358
+ writer.writerows(comparison_data)
359
+ logger.info(f"比較データ出力完了: {fpath}")
360
  except Exception as e:
361
+ logger.error(f"比較データ出力失敗 ({fpath}): {e}")
 
 
 
 
362
 
 
 
 
 
 
363
 
364
+ # --- ファイル処理関数 ---
365
+ def process_file(
366
+ filepath: str, output_folder: str, normalizer: TextNormalizer, pron_estimator: PronunciationEstimator,
367
+ output_formats: List[Literal['tsv', 'jsonl']], lab_output_enabled: bool, reading_output_format: Literal['hiragana', 'katakana'],
368
+ encoding: str, output_comparison: bool = False,
369
+ ) -> Tuple[int, int]:
370
+ """ファイルを処理し、コーパスデータを出力"""
371
  filename = os.path.basename(filepath)
372
+ output_base = os.path.join(output_folder, os.path.splitext(filename)[0])
373
+ processed_data: List[Dict[str, Any]] = []
374
+ comparison_items: List[Dict[str, str]] = []
375
+ all_lab_data: Dict[str, List[str]] = {}
376
+ success_count = 0
377
+ error_count = 0
378
+
379
  try:
380
+ original_lines = read_text_with_bom_removal(filepath, encoding)
381
+ logger.debug(f"'{filename}'読み込み完了、{len(original_lines)}行")
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
+ for i, line in enumerate(original_lines):
384
+ line_id = f"{os.path.splitext(filename)[0]}_{i:04d}"
385
+ original_line = line.strip()
386
+ if not original_line: continue
387
 
388
+ normalized_line = "[NORM ERROR]"
389
+ reading = "[READING ERROR]"
390
+ lab_data = []
391
+ line_success = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
 
 
 
 
393
  try:
394
+ normalized_line = normalizer.normalize(original_line)
395
+ if not normalized_line: logger.warning(f"{line_id}: 正規化後空"); continue
396
+
397
+ reading, _, lab_data = pron_estimator.get_pronunciation(normalized_line, reading_output_format)
398
+ if "[ERROR]" not in reading: line_success = True
399
+
400
+ except NormalizationError as e_norm: logger.error(f"{line_id}: 正規化エラー: {e_norm}")
401
+ except Exception as e_line: logger.error(f"{line_id}: 行処理エラー: {e_line}", exc_info=True)
402
+
403
+ # 結果格納
404
+ if line_success: success_count += 1
405
+ else: error_count += 1
406
+
407
+ corpus_item = {"id": line_id, "text": normalized_line, "reading": reading}
408
+ processed_data.append(corpus_item)
409
+ if lab_output_enabled and lab_data: all_lab_data[line_id] = lab_data
410
+ if output_comparison:
411
+ comparison_items.append({"id": line_id, "original": original_line, "normalized": normalized_line, "reading": reading})
412
+
413
+ # ファイル単位出力
414
+ if processed_data: output_corpus_data(output_base, processed_data, output_formats)
415
+ if lab_output_enabled and all_lab_data: output_lab_data(output_base, all_lab_data)
416
+ if output_comparison and comparison_items: output_comparison_data(output_base, comparison_items)
417
+
418
+ logger.debug(f"'{filename}'処理完了 S:{success_count}, E:{error_count}")
419
+ return success_count, error_count
420
+
421
+ except FileProcessingError as e_fp: raise e_fp # 再throw
422
+ except Exception as e_f: raise FileProcessingError(f"ファイル処理中エラー ({filepath}): {e_f}")
423
+
424
+
425
+ # --- GUI アプリケーションクラス ---
426
+ class TextProcessorGUI(tk.Tk):
427
+ def __init__(self):
428
+ super().__init__()
429
+ # --- ウィンドウ設定 ---
430
+ self.title("音声合成コーパス前処理ツール")
431
+ self.geometry("850x700")
432
+ style = ttk.Style(self)
433
+ try: style.theme_use('clam') # or 'alt', 'default', 'classic'
434
+ except tk.TclError: pass # テーマが存在しない場合
435
+
436
+ # --- 変数定義 ---
437
+ self.input_files_var = tk.StringVar(value="")
438
+ self.output_folder_var = tk.StringVar(value="")
439
+ self.norm_rules_var = tk.StringVar(value="normalization_rules.yaml") # デフォルトファイル名
440
+ self.janome_udic_var = tk.StringVar(value="")
441
+ self.jtalk_dic_var = tk.StringVar(value="")
442
+ self.jtalk_user_dic_var = tk.StringVar(value="")
443
+ # 利用可能なエンジンのみ選択肢に含める
444
+ engine_choices = [eng for eng in ['janome', 'pyopenjtalk'] if globals()[f"{eng.upper()}_AVAILABLE"]]
445
+ default_engine = 'pyopenjtalk' if PYOPENJTALK_AVAILABLE else ('janome' if JANOME_AVAILABLE else '')
446
+ self.engine_var = tk.StringVar(value=default_engine)
447
+ self.output_format_vars = {"tsv": tk.BooleanVar(value=False), "jsonl": tk.BooleanVar(value=True)}
448
+ self.reading_format_var = tk.StringVar(value="katakana")
449
+ self.output_lab_var = tk.BooleanVar(value=False)
450
+ self.output_comp_var = tk.BooleanVar(value=False)
451
+ self.encoding_var = tk.StringVar(value="utf-8")
452
+ self.processing_active = False # 処理中フラグ
453
+
454
+ # --- ウィジェット作成 & ロギング設定 ---
455
+ self._create_widgets()
456
+ self._setup_logging()
457
+
458
+ # --- エンジン利用不可の場合の警告 ---
459
+ if not PYOPENJTALK_AVAILABLE:
460
+ logger.warning("pyopenjtalkが見つからないか初期化に失敗しました。pyopenjtalkエンジンは選択できません。")
461
+ if not JANOME_AVAILABLE:
462
+ logger.warning("Janomeが見つかりません。Janomeエンジンは選択できません。")
463
+ if not engine_choices:
464
+ messagebox.showerror("致命的エラー", "利用可能な読み推定エンジン(Janome/pyopenjtalk)がありません。\nライブラリのインストールを確認してください。")
465
+ self.destroy() # アプリケーション終了
466
+
467
+ def _create_widgets(self):
468
+ main_frame = ttk.Frame(self, padding="10")
469
+ main_frame.pack(expand=True, fill=tk.BOTH)
470
+ main_frame.columnconfigure(1, weight=1) # 2列目が伸びるように
471
+
472
+ row_idx = 0
473
+
474
+ # --- 入出力パス ---
475
+ io_frame = ttk.LabelFrame(main_frame, text=" 入出力 ", padding="10")
476
+ io_frame.grid(row=row_idx, column=0, columnspan=3, sticky="ew", padx=5, pady=(0,5))
477
+ io_frame.columnconfigure(1, weight=1)
478
+ ttk.Label(io_frame, text="入力ファイル:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
479
+ ttk.Entry(io_frame, textvariable=self.input_files_var).grid(row=0, column=1, sticky="ew", padx=5, pady=2)
480
+ ttk.Button(io_frame, text="選択...", width=8, command=self._select_input_files).grid(row=0, column=2, padx=(0,5), pady=2)
481
+ ttk.Label(io_frame, text="出力フォルダ:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2)
482
+ ttk.Entry(io_frame, textvariable=self.output_folder_var).grid(row=1, column=1, sticky="ew", padx=5, pady=2)
483
+ ttk.Button(io_frame, text="選択...", width=8, command=self._select_output_folder).grid(row=1, column=2, padx=(0,5), pady=2)
484
+ row_idx += 1
485
+
486
+ # --- 設定ファイル ---
487
+ config_frame = ttk.LabelFrame(main_frame, text=" 設定ファイル ", padding="10")
488
+ config_frame.grid(row=row_idx, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
489
+ config_frame.columnconfigure(1, weight=1)
490
+ ttk.Label(config_frame, text="正規化ルール:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
491
+ ttk.Entry(config_frame, textvariable=self.norm_rules_var).grid(row=0, column=1, sticky="ew", padx=5, pady=2)
492
+ ttk.Button(config_frame, text="選択...", width=8, command=self._select_norm_rules).grid(row=0, column=2, padx=(0,5), pady=2)
493
+ row_idx += 1
494
+
495
+ # --- エンジンと辞書 ---
496
+ engine_frame = ttk.LabelFrame(main_frame, text=" 読み推定エンジンと辞書 ", padding="10")
497
+ engine_frame.grid(row=row_idx, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
498
+ engine_frame.columnconfigure(1, weight=1)
499
+ engine_choices = [eng for eng in ['janome', 'pyopenjtalk'] if globals()[f"{eng.upper()}_AVAILABLE"]]
500
+ ttk.Label(engine_frame, text="エンジン:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
501
+ engine_combo = ttk.Combobox(engine_frame, textvariable=self.engine_var, values=engine_choices, state="readonly", width=15)
502
+ engine_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2)
503
+
504
+ ttk.Label(engine_frame, text="Janomeユーザー辞書:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2)
505
+ ttk.Entry(engine_frame, textvariable=self.janome_udic_var).grid(row=1, column=1, sticky="ew", padx=5, pady=2)
506
+ ttk.Button(engine_frame, text="選択...", width=8, command=lambda: self._select_file(self.janome_udic_var, "Janome辞書", [("CSV", "*.csv")])).grid(row=1, column=2, padx=(0,5), pady=2)
507
+
508
+ ttk.Label(engine_frame, text="JTalkシステム辞書:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2)
509
+ ttk.Entry(engine_frame, textvariable=self.jtalk_dic_var).grid(row=2, column=1, sticky="ew", padx=5, pady=2)
510
+ ttk.Button(engine_frame, text="選択...", width=8, command=self._select_jtalk_dic).grid(row=2, column=2, padx=(0,5), pady=2)
511
+
512
+ ttk.Label(engine_frame, text="JTalkユーザー辞書:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2)
513
+ ttk.Entry(engine_frame, textvariable=self.jtalk_user_dic_var).grid(row=3, column=1, sticky="ew", padx=5, pady=2)
514
+ ttk.Button(engine_frame, text="選択...", width=8, command=lambda: self._select_file(self.jtalk_user_dic_var, "JTalkユーザー辞書", [("CSV/DIC", "*.csv *.dic")])).grid(row=3, column=2, padx=(0,5), pady=2)
515
+ row_idx += 1
516
+
517
+ # --- 出力オプション ---
518
+ output_frame = ttk.LabelFrame(main_frame, text=" 出力オプション ", padding="10")
519
+ output_frame.grid(row=row_idx, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
520
+ output_frame.columnconfigure(1, weight=1) # 右側のスペースを確保
521
+
522
+ # コーパス形式 (横並び)
523
+ cf_frame = ttk.Frame(output_frame)
524
+ cf_frame.grid(row=0, column=0, columnspan=4, sticky=tk.W, pady=2)
525
+ ttk.Label(cf_frame, text="コーパス形式:").pack(side=tk.LEFT, padx=(5,10))
526
+ ttk.Checkbutton(cf_frame, text="TSV", variable=self.output_format_vars["tsv"]).pack(side=tk.LEFT, padx=5)
527
+ ttk.Checkbutton(cf_frame, text="JSONL", variable=self.output_format_vars["jsonl"]).pack(side=tk.LEFT, padx=5)
528
+
529
+ # 読みの形式
530
+ rf_frame = ttk.Frame(output_frame)
531
+ rf_frame.grid(row=1, column=0, columnspan=4, sticky=tk.W, pady=2)
532
+ ttk.Label(rf_frame, text="読みの形式:").pack(side=tk.LEFT, padx=(5,10))
533
+ reading_combo = ttk.Combobox(rf_frame, textvariable=self.reading_format_var, values=['katakana', 'hiragana'], state="readonly", width=10)
534
+ reading_combo.pack(side=tk.LEFT, padx=5)
535
+
536
+ # その他チェックボックス (横並び)
537
+ oc_frame = ttk.Frame(output_frame)
538
+ oc_frame.grid(row=2, column=0, columnspan=4, sticky=tk.W, pady=2)
539
+ ttk.Checkbutton(oc_frame, text="LAB出力 (JTalk)", variable=self.output_lab_var).pack(side=tk.LEFT, padx=5)
540
+ ttk.Checkbutton(oc_frame, text="比較データ出力", variable=self.output_comp_var).pack(side=tk.LEFT, padx=5)
541
+
542
+ # エンコーディング
543
+ enc_frame = ttk.Frame(output_frame)
544
+ enc_frame.grid(row=3, column=0, columnspan=4, sticky=tk.W, pady=2)
545
+ ttk.Label(enc_frame, text="入力エンコーディング:").pack(side=tk.LEFT, padx=(5, 10))
546
+ encoding_entry = ttk.Entry(enc_frame, textvariable=self.encoding_var, width=12)
547
+ encoding_entry.pack(side=tk.LEFT, padx=5)
548
+ row_idx += 1
549
+
550
+ # --- 実行ボタン ---
551
+ self.run_button = ttk.Button(main_frame, text="処理実行", command=self._start_processing, style="Accent.TButton") # スタイル適用例
552
+ self.run_button.grid(row=row_idx, column=0, columnspan=3, pady=15)
553
+ row_idx += 1
554
+
555
+ # --- ログ表示 ---
556
+ log_frame = ttk.LabelFrame(main_frame, text=" ログ ", padding="10")
557
+ log_frame.grid(row=row_idx, column=0, columnspan=3, sticky="nsew", padx=5, pady=(5,0))
558
+ main_frame.rowconfigure(row_idx, weight=1) # ログエリアが伸縮するように
559
+ log_frame.grid_rowconfigure(0, weight=1)
560
+ log_frame.grid_columnconfigure(0, weight=1)
561
+ self.log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=10, state=tk.DISABLED, bd=0, relief=tk.FLAT) # ボーダー調整
562
+ self.log_text.grid(row=0, column=0, sticky="nsew")
563
+
564
+ # --- ファイル/フォルダ選択メソッド ---
565
+ def _select_input_files(self):
566
+ fpaths = filedialog.askopenfilenames(title="入力ファイル選択 (複数可)", filetypes=[("テキストファイル", "*.txt"), ("全ファイル", "*.*")])
567
+ if fpaths: self.input_files_var.set(";".join(fpaths)) # 区切り文字で結合
568
+
569
+ def _select_output_folder(self):
570
+ fpath = filedialog.askdirectory(title="出力フォルダ選択")
571
+ if fpath: self.output_folder_var.set(fpath)
572
+
573
+ def _select_file(self, var, title, filetypes):
574
+ fpath = filedialog.askopenfilename(title=title, filetypes=filetypes)
575
+ if fpath: var.set(fpath)
576
+
577
+ def _select_norm_rules(self):
578
+ self._select_file(self.norm_rules_var, "正規化ルール選択", [("YAML", "*.yaml *.yml"), ("全ファイル", "*.*")])
579
+
580
+ def _select_jtalk_dic(self):
581
+ fpath = filedialog.askdirectory(title="JTalkシステム辞書フォルダ選択 (例: .../open_jtalk_dic_utf_8-1.11)")
582
+ if fpath: self.jtalk_dic_var.set(fpath)
583
+
584
+ # --- ロギング設定 ---
585
+ def _setup_logging(self):
586
+ class TextHandler(logging.Handler):
587
+ def __init__(self, text_widget):
588
+ logging.Handler.__init__(self)
589
+ self.text_widget = text_widget
590
+ self.queue = [] # メッセージキュー
591
+ self.processing = False
592
+
593
+ def schedule_update(self):
594
+ # 複数メッセージをまとめて更新
595
+ if not self.processing:
596
+ self.processing = True
597
+ self.text_widget.after(50, self._update_widget) # 50ms後に更新
598
+
599
+ def _update_widget(self):
600
+ self.processing = False
601
+ if not self.text_widget.winfo_exists(): return
602
+
603
+ self.text_widget.configure(state='normal')
604
+ while self.queue:
605
+ record = self.queue.pop(0)
606
+ msg = self.format(record)
607
+ self.text_widget.insert(tk.END, msg + '\n')
608
+ self.text_widget.configure(state='disabled')
609
+ self.text_widget.yview(tk.END)
610
+
611
+ def emit(self, record):
612
+ self.queue.append(record)
613
+ self.schedule_update()
614
+
615
+ gui_handler = TextHandler(self.log_text)
616
+ gui_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%H:%M:%S'))
617
+ logging.getLogger().addHandler(gui_handler)
618
+ logging.getLogger().setLevel(logging.INFO) # INFOレベル以上をGUIに表示
619
+
620
+ # --- 処理実行メソッド ---
621
+ def _start_processing(self):
622
+ if self.processing_active: logger.warning("処理中"); return
623
+
624
+ # --- 設定値取得 & バリデーション ---
625
+ self.params = {} # パラメータを格納する辞書
626
+ try:
627
+ self.params['input_files'] = [f.strip() for f in self.input_files_var.get().split(';') if f.strip()]
628
+ self.params['output_folder'] = self.output_folder_var.get()
629
+ self.params['norm_rules'] = self.norm_rules_var.get()
630
+ self.params['engine'] = self.engine_var.get()
631
+ self.params['janome_udic'] = self.janome_udic_var.get() or None
632
+ self.params['jtalk_dic'] = self.jtalk_dic_var.get() or None
633
+ self.params['jtalk_user_dic'] = self.jtalk_user_dic_var.get() or None
634
+ self.params['output_format'] = [fmt for fmt, var in self.output_format_vars.items() if var.get()]
635
+ self.params['reading_format'] = self.reading_format_var.get()
636
+ self.params['output_lab'] = self.output_lab_var.get()
637
+ self.params['output_comparison'] = self.output_comp_var.get()
638
+ self.params['encoding'] = self.encoding_var.get().strip() or 'utf-8'
639
+
640
+ if not self.params['input_files']: raise ValueError("入力ファイル未選択")
641
+ if not self.params['output_folder']: raise ValueError("出力フォルダ未選択")
642
+ if not self.params['output_format']: raise ValueError("出力コーパス形式未選択")
643
+ if self.params['output_lab'] and self.params['engine'] != 'pyopenjtalk':
644
+ raise ValueError("LAB出力はpyopenjtalkエンジン選択時のみ可能")
645
+ if not os.path.exists(self.params['norm_rules']):
646
+ logger.warning(f"正規化ルールファイル '{self.params['norm_rules']}' が存在しません。デフォルトルールを使用します。")
647
+ # デフォルトを使用する場合、ファイルパスはNoneとして扱う方が良いかも
648
+ # self.params['norm_rules'] = None # またはそのまま存在しないパスを渡す
649
+
650
+ except ValueError as ve:
651
+ messagebox.showerror("入力エラー", str(ve)); return
652
+ except Exception as e:
653
+ messagebox.showerror("設定エラー", f"設定値の取得中にエラー: {e}"); return
654
+
655
+ # 処理開始
656
+ self.processing_active = True
657
+ self.run_button.config(state=tk.DISABLED, text="処理中...")
658
+ self.log_text.configure(state='normal'); self.log_text.delete('1.0', tk.END); self.log_text.configure(state='disabled')
659
+ logger.info("処理を開始します...")
660
+
661
+ # 別スレッドで実行
662
+ self.processing_thread = threading.Thread(target=self._run_processing_logic, daemon=True)
663
+ self.processing_thread.start()
664
+ self.after(100, self._check_thread) # スレッド監視開始
665
+
666
+ def _check_thread(self):
667
+ if self.processing_thread.is_alive():
668
+ self.after(100, self._check_thread)
669
+ # スレッド終了後の処理は _run_processing_logic の最後で after(0, ...) を使う
670
+
671
+ def _run_processing_logic(self):
672
+ """実際の処理 (別スレッドで実行)"""
673
+ exit_code = 1
674
+ final_message = "処理中にエラーが発生しました。"
675
+ message_type = "error"
676
+ p = self.params # 短縮名
677
+
678
+ try:
679
+ # --- ログ表示 (主要パラメータ) ---
680
+ logger.info(f"入力ファイル数: {len(p['input_files'])}")
681
+ logger.info(f"出力フォルダ: {p['output_folder']}")
682
+ logger.info(f"正規化ルール: {p['norm_rules'] if os.path.exists(p['norm_rules']) else 'デフォルト'}")
683
+ logger.info(f"エンジン: {p['engine']}")
684
+ logger.info(f"出力形式: {p['output_format']}")
685
+ logger.info(f"LAB出力: {p['output_lab']}")
686
+
687
+ # --- 初期化 ---
688
+ normalizer = TextNormalizer(p['norm_rules'])
689
+ pron_estimator = PronunciationEstimator(
690
+ engine=p['engine'], janome_udic_path=p['janome_udic'],
691
+ jtalk_dic_path=p['jtalk_dic'], jtalk_user_dic_path=p['jtalk_user_dic']
692
+ )
693
+
694
+ # --- ファイル処理ループ ---
695
+ total_success_lines, total_error_lines, file_error_count, processed_files = 0, 0, 0, 0
696
+ for i, filepath in enumerate(p['input_files']):
697
+ logger.info(f"--- 処理中 ({i+1}/{len(p['input_files'])}): {os.path.basename(filepath)} ---")
698
+ try:
699
+ norm_filepath = os.path.normpath(filepath)
700
+ if not os.path.isfile(norm_filepath):
701
+ logger.warning(f"スキップ (非ファイル): {norm_filepath}"); file_error_count += 1; continue
702
+ s, e = process_file( # process_fileは (成功数, エラー数) を返す想定
703
+ norm_filepath, p['output_folder'], normalizer, pron_estimator,
704
+ p['output_format'], p['output_lab'], p['reading_format'],
705
+ p['encoding'], p['output_comparison']
706
+ )
707
+ total_success_lines += s; total_error_lines += e
708
+ if e > 0: file_error_count += 1
709
+ processed_files += 1
710
+ except FileProcessingError as e_fp: logger.error(f"{os.path.basename(filepath)} ファイルエラー: {e_fp}"); file_error_count += 1
711
+ except Exception as e_f: logger.error(f"{os.path.basename(filepath)} 予期せぬエラー: {e_f}", exc_info=True); file_error_count += 1
712
+ # logger.info(f"--- 処理完了 ({i+1}/{len(p['input_files'])}): {os.path.basename(filepath)} ---") # ログが冗長ならコメントアウト
713
+
714
+ # --- 未知語出力 ---
715
+ if pron_estimator.unknown_words:
716
+ unknown_file = os.path.join(p['output_folder'], "unknown_words.txt")
717
+ try:
718
+ with open(unknown_file, 'w', encoding='utf-8') as f:
719
+ f.write("\n".join(sorted(list(pron_estimator.unknown_words))))
720
+ logger.warning(f"未知語リスト出力: {unknown_file}")
721
+ except Exception as e_unk: logger.error(f"未知語リスト出力失敗: {e_unk}")
722
+
723
+ # --- 最終結果判定 ---
724
+ logger.info("=" * 30 + " 処理結果 " + "=" * 30)
725
+ logger.info(f"処理ファイル数: {processed_files} / {len(p['input_files'])}")
726
+ logger.info(f"エラー発生ファイル数: {file_error_count}")
727
+ logger.info(f"総処理行数: {total_success_lines + total_error_lines}")
728
+ logger.info(f" 正常処理行数: {total_success_lines}")
729
+ logger.info(f" エラー発生行数: {total_error_lines}")
730
+ logger.info("=" * 68)
731
+
732
+ if file_error_count > 0 or total_error_lines > 0:
733
+ final_message = "処理完了(エラーあり)。詳細はログを確認してください。"
734
+ message_type = "warning"
735
+ exit_code = 1
736
+ else:
737
+ final_message = "すべての処理が正常に完了しました。"
738
+ message_type = "info"
739
+ exit_code = 0
740
+
741
+ except InitializationError as e_init: final_message = f"初期化エラー: {e_init}"; message_type = "error"
742
+ except Exception as e_main: final_message = f"予期せぬエラー: {e_main}"; message_type = "error"; logger.error("予期せぬエラー", exc_info=True)
743
+
744
+ # --- GUI要素の更新 (メインスレッドで実行) ---
745
+ def update_gui():
746
+ self.processing_active = False
747
+ if self.winfo_exists(): # ウィンドウが閉じられていないか確認
748
+ self.run_button.config(state=tk.NORMAL, text="処理実行")
749
+ if message_type == "info": messagebox.showinfo("完了", final_message)
750
+ elif message_type == "warning": messagebox.showwarning("完了(一部エラー)", final_message)
751
+ else: messagebox.showerror("エラー", final_message)
752
+ self.after(0, update_gui)
753
+ # スレッド終了
754
+
755
+
756
+ # --- アプリケーション起動 ---
757
  if __name__ == "__main__":
758
+ if not ENABLE_GUI:
759
+ print("エラー: GUI表示に必要な tkinter が見つかりません。", file=sys.stderr)
760
+ sys.exit(1)
761
+
762
+ # 利用可能なエンジンがない場合のチェックはGUIクラスの __init__ で行う
763
+ app = TextProcessorGUI()
764
+ app.mainloop()