techiaith-speech-normalizer 0.2.0__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.
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Welsh Verbatim Speech Normalizer - Configurable Version
4
+
5
+ This module provides configurable normalization for Welsh verbatim speech
6
+ transcriptions. You decide which patterns to normalize.
7
+
8
+ Usage:
9
+ from welsh_normalizer import WelshNormalizer
10
+
11
+ # Create with your choices
12
+ normalizer = WelshNormalizer(
13
+ remove_tags=True,
14
+ remove_asterisks=True,
15
+ expand_pronouns=True,
16
+ expand_contractions=True,
17
+ standardize_spelling=True,
18
+ join_hyphenated_compounds=True, # gwasg-y-fferm → gwasgyfferm
19
+ # etc.
20
+ )
21
+
22
+ result = normalizer.normalize("bo' nw'n gwbod <anadlu> *Facebook*")
23
+ """
24
+
25
+ import re
26
+ import sys
27
+ import unicodedata
28
+ from pathlib import Path
29
+
30
+ from typing import Dict, List, Optional, Set, Tuple
31
+ from dataclasses import dataclass
32
+ from enum import Enum
33
+
34
+ # Handle imports for both module and script execution
35
+ try:
36
+ from .tokenization import tokenize_with_punctuation, detokenize
37
+ except ImportError:
38
+ # Running as script, add parent directory to path
39
+ sys.path.insert(0, str(Path(__file__).parent))
40
+ from tokenization import tokenize_with_punctuation, detokenize
41
+
42
+ # Import shared Welsh language data
43
+ try:
44
+ from .welsh_data import (
45
+ PRONOUN_CORRECTIONS,
46
+ VERB_CONTRACTIONS,
47
+ MAE_CONTRACTIONS,
48
+ OTHER_CONTRACTIONS,
49
+ PLURAL_REDUCTIONS,
50
+ VERB_REDUCTIONS,
51
+ ADJECTIVE_REDUCTIONS,
52
+ INTERNAL_REDUCTIONS,
53
+ SPELLING_CORRECTIONS,
54
+ HESITATION_STANDARDIZATION,
55
+ MA_FOLLOWERS,
56
+ DI_WEDI_SAFE_PRECEDERS,
57
+ MULTI_WORD_REPLACEMENTS,
58
+ )
59
+ except ImportError:
60
+ from normalization.welsh_data import (
61
+ PRONOUN_CORRECTIONS,
62
+ VERB_CONTRACTIONS,
63
+ MAE_CONTRACTIONS,
64
+ OTHER_CONTRACTIONS,
65
+ PLURAL_REDUCTIONS,
66
+ VERB_REDUCTIONS,
67
+ ADJECTIVE_REDUCTIONS,
68
+ INTERNAL_REDUCTIONS,
69
+ SPELLING_CORRECTIONS,
70
+ HESITATION_STANDARDIZATION,
71
+ MA_FOLLOWERS,
72
+ DI_WEDI_SAFE_PRECEDERS,
73
+ MULTI_WORD_REPLACEMENTS,
74
+ )
75
+
76
+
77
+ class FlagType(Enum):
78
+ """Types of flags for uncertain normalizations."""
79
+ CONTEXT_UNCERTAIN = "context_uncertain"
80
+ REGIONAL_VARIANT = "regional_variant"
81
+ AMBIGUOUS = "ambiguous"
82
+
83
+
84
+ @dataclass
85
+ class NormalizationFlag:
86
+ """A flag indicating an uncertain normalization."""
87
+ original: str
88
+ normalized: str
89
+ flag_type: FlagType
90
+ context: str
91
+ position: int
92
+ suggestion: str
93
+
94
+ def __str__(self):
95
+ """String representation for CSV serialization."""
96
+ return f"{self.flag_type.value}:{self.original}"
97
+
98
+
99
+ class WelshNormalizer:
100
+ """
101
+ Configurable normalizer for Welsh verbatim speech transcriptions.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ remove_tags: bool = False,
107
+ remove_asterisks: bool = False,
108
+ expand_pronouns: bool = True,
109
+ expand_verb_contractions: bool = True,
110
+ expand_mae_contractions: bool = True,
111
+ normalize_ma_with_followers: bool = True, # New: use MA_FOLLOWERS lookup table
112
+ expand_di_contextual: bool = True, # New: context-aware 'di expansion
113
+ expand_other_contractions: bool = True,
114
+ standardize_plurals: bool = True,
115
+ standardize_verbs: bool = True,
116
+ standardize_adjectives: bool = True,
117
+ standardize_internal: bool = True,
118
+ standardize_spelling: bool = True,
119
+ remove_hesitations: bool = False,
120
+ standardize_hesitations: bool = True,
121
+ remove_false_starts: bool = True,
122
+ expand_annotations: bool = True, # {llafar|ysgrifenedig} -> ysgrifenedig, e.g. {saith|7} -> 7
123
+ normalize_whitespace: bool = True,
124
+ flag_uncertain: bool = True, # New: flag uncertain normalizations
125
+ expand_multi_word: bool = True, # New: multi-word phrase replacements
126
+ normalize_numbers: bool = False, # Convert number words to digits (for WER)
127
+ join_hyphenated_compounds: bool = False, # Join hyphenated compounds into single tokens (for WER)
128
+ lowercase: bool = False, # Lowercase output (for WER calculation)
129
+ strip_punctuation: bool = False, # Strip punctuation/symbols (for WER calculation)
130
+ custom_corrections: Optional[Dict[str, str]] = None,
131
+ custom_multi_word: Optional[Dict[str, str]] = None,
132
+ ):
133
+ self.remove_tags = remove_tags
134
+ self.remove_asterisks = remove_asterisks
135
+ self.expand_pronouns = expand_pronouns
136
+ self.expand_verb_contractions = expand_verb_contractions
137
+ self.expand_mae_contractions = expand_mae_contractions
138
+ self.normalize_ma_with_followers = normalize_ma_with_followers
139
+ self.expand_di_contextual = expand_di_contextual
140
+ self.expand_other_contractions = expand_other_contractions
141
+ self.standardize_plurals = standardize_plurals
142
+ self.standardize_verbs = standardize_verbs
143
+ self.standardize_adjectives = standardize_adjectives
144
+ self.standardize_internal = standardize_internal
145
+ self.standardize_spelling = standardize_spelling
146
+ self.remove_hesitations = remove_hesitations
147
+ self.standardize_hesitations = standardize_hesitations
148
+ self.remove_false_starts = remove_false_starts
149
+ self.expand_annotations = expand_annotations
150
+ self.normalize_whitespace = normalize_whitespace
151
+ self.flag_uncertain = flag_uncertain
152
+ self.expand_multi_word = expand_multi_word
153
+ self.normalize_numbers = normalize_numbers
154
+ self.join_hyphenated_compounds = join_hyphenated_compounds
155
+ self.lowercase = lowercase
156
+ self.strip_punctuation = strip_punctuation
157
+
158
+ if self.normalize_numbers:
159
+ from .welsh_number_normalizer import WelshNumberNormalizer
160
+ self._number_normalizer = WelshNumberNormalizer()
161
+
162
+ self._build_corrections(custom_corrections, custom_multi_word)
163
+
164
+ self._tag_pattern = re.compile(r'<[^>]+>')
165
+ self._asterisk_pattern = re.compile(r'\*([^*]+)\*')
166
+ # {spoken|written} annotation: keep the written form, e.g. {saith|7} -> 7
167
+ self._annotation_pattern = re.compile(r'\{[^{}|]*\|([^{}]*)\}')
168
+ self._hesitation_pattern = re.compile(r'\b(yy+m*|yrm|ymm*)\b', re.IGNORECASE)
169
+ self._false_start_pattern = re.compile(r'\b\w+-\s')
170
+ self._multi_space_pattern = re.compile(r' +')
171
+ self._punctuation_pattern = re.compile(r'[^\w\s]', re.UNICODE)
172
+ self._hyphen_compound_pattern = re.compile(r'(?<=\w)[-\u2013\u2014](?=\w)')
173
+
174
+ def _build_corrections(self, custom: Optional[Dict[str, str]], custom_multi: Optional[Dict[str, str]]):
175
+ self.corrections = {}
176
+ self.multi_word_corrections = {}
177
+
178
+ if self.expand_pronouns:
179
+ self.corrections.update(PRONOUN_CORRECTIONS)
180
+ if self.expand_verb_contractions:
181
+ self.corrections.update(VERB_CONTRACTIONS)
182
+ if self.expand_mae_contractions:
183
+ self.corrections.update(MAE_CONTRACTIONS)
184
+ if self.expand_other_contractions:
185
+ self.corrections.update(OTHER_CONTRACTIONS)
186
+ if self.standardize_plurals:
187
+ self.corrections.update(PLURAL_REDUCTIONS)
188
+ if self.standardize_verbs:
189
+ self.corrections.update(VERB_REDUCTIONS)
190
+ if self.standardize_adjectives:
191
+ self.corrections.update(ADJECTIVE_REDUCTIONS)
192
+ if self.standardize_internal:
193
+ self.corrections.update(INTERNAL_REDUCTIONS)
194
+ if self.standardize_spelling:
195
+ self.corrections.update(SPELLING_CORRECTIONS)
196
+ if self.standardize_hesitations:
197
+ self.corrections.update(HESITATION_STANDARDIZATION)
198
+ if custom:
199
+ self.corrections.update(custom)
200
+
201
+ if self.expand_multi_word:
202
+ self.multi_word_corrections.update(MULTI_WORD_REPLACEMENTS)
203
+ if custom_multi:
204
+ self.multi_word_corrections.update(custom_multi)
205
+
206
+ self.corrections_lower = {k.lower(): v.lower() for k, v in self.corrections.items()}
207
+
208
+ # Build lookup structure for multi-word patterns
209
+ # Group by number of words for efficient matching
210
+ self.multi_word_by_length = {}
211
+ for phrase, replacement in self.multi_word_corrections.items():
212
+ phrase_lower = phrase.lower()
213
+ word_count = len(phrase_lower.split())
214
+ if word_count not in self.multi_word_by_length:
215
+ self.multi_word_by_length[word_count] = {}
216
+ self.multi_word_by_length[word_count][phrase_lower] = replacement
217
+
218
+
219
+
220
+ def __call__(self, text: str) -> str:
221
+ """Make the normalizer callable, consistent with Whisper's normalizer API."""
222
+ return self.normalize(text)
223
+
224
+ def normalize(self, text: str) -> str:
225
+ """
226
+ Apply normalization and return the result.
227
+
228
+ Returns:
229
+ Normalized text
230
+ """
231
+ result, _ = self.normalize_with_flags(text)
232
+ return result
233
+
234
+ def normalize_with_flags(self, text: str) -> Tuple[str, List[NormalizationFlag]]:
235
+ """
236
+ Apply normalization and return both result and any flags.
237
+
238
+ Returns:
239
+ Tuple of (normalized_text, list_of_flags)
240
+ """
241
+ if not text:
242
+ return text, []
243
+
244
+ flags = []
245
+ result = unicodedata.normalize('NFKC', text)
246
+
247
+ if self.expand_annotations:
248
+ result = self._annotation_pattern.sub(r'\1', result)
249
+
250
+ if self.remove_tags:
251
+ result = self._tag_pattern.sub('', result)
252
+
253
+ if self.remove_asterisks:
254
+ result = self._asterisk_pattern.sub(r'\1', result)
255
+
256
+ if self.remove_hesitations:
257
+ result = self._hesitation_pattern.sub('', result)
258
+
259
+ if self.remove_false_starts:
260
+ result = self._false_start_pattern.sub('', result)
261
+
262
+ if self.corrections or self.multi_word_corrections or self.normalize_ma_with_followers or self.expand_di_contextual:
263
+ result, new_flags = self._apply_corrections(result, text)
264
+ flags.extend(new_flags)
265
+
266
+ if self.normalize_numbers:
267
+ result = self._number_normalizer.normalize(result)
268
+
269
+ if self.join_hyphenated_compounds:
270
+ result = self._hyphen_compound_pattern.sub(' ', result)
271
+
272
+ if self.strip_punctuation:
273
+ result = self._punctuation_pattern.sub(' ', result)
274
+
275
+ if self.lowercase:
276
+ result = result.lower()
277
+
278
+ if self.normalize_whitespace:
279
+ result = self._multi_space_pattern.sub(' ', result)
280
+ result = result.strip()
281
+
282
+ return result, flags
283
+
284
+ def _apply_corrections(self, text: str, full_text: str) -> Tuple[str, List[NormalizationFlag]]:
285
+ # Tokenize with punctuation separation
286
+ tokens = tokenize_with_punctuation(text)
287
+
288
+ # Extract just the words for processing
289
+ word_indices = [i for i, (token, ttype) in enumerate(tokens) if ttype == 'word']
290
+
291
+ flags = []
292
+ i = 0 # Index in word_indices
293
+
294
+ while i < len(word_indices):
295
+ token_idx = word_indices[i]
296
+ word = tokens[token_idx][0]
297
+ word_lower = word.lower()
298
+
299
+ # Check if preceded by an apostrophe — if so, treat as combined form
300
+ # e.g. tokens ['", "di"] should be matched as "'di"
301
+ has_leading_apos = (token_idx > 0 and tokens[token_idx - 1][0] in ("'", "\u2019")
302
+ and tokens[token_idx - 1][1] == 'punct')
303
+ if has_leading_apos:
304
+ word_with_apos = "'" + word
305
+ word_with_apos_lower = "'" + word_lower
306
+ else:
307
+ word_with_apos = None
308
+ word_with_apos_lower = None
309
+
310
+ # Get context (previous and next words, not punctuation)
311
+ prev_word = tokens[word_indices[i-1]][0].lower() if i > 0 else None
312
+ next_word = tokens[word_indices[i+1]][0].lower() if i + 1 < len(word_indices) else None
313
+
314
+ # Try multi-word patterns first (check longest patterns first)
315
+ multi_word_match = False
316
+ if self.expand_multi_word:
317
+ for word_count in sorted(self.multi_word_by_length.keys(), reverse=True):
318
+ if i + word_count <= len(word_indices):
319
+ # Build the potential phrase from current position
320
+ phrase_word_indices = [word_indices[i+j] for j in range(word_count)]
321
+ phrase_words = [tokens[idx][0] for idx in phrase_word_indices]
322
+ phrase_lower = ' '.join(w.lower() for w in phrase_words)
323
+
324
+ # Also try with apostrophe-prefixed first word
325
+ first_token_idx = phrase_word_indices[0]
326
+ first_has_apos = (first_token_idx > 0
327
+ and tokens[first_token_idx - 1][0] in ("'", "\u2019")
328
+ and tokens[first_token_idx - 1][1] == 'punct')
329
+ if first_has_apos:
330
+ phrase_apos_lower = "'" + phrase_lower
331
+ else:
332
+ phrase_apos_lower = None
333
+
334
+ lookup = self.multi_word_by_length[word_count]
335
+ matched_key = None
336
+ if phrase_apos_lower and phrase_apos_lower in lookup:
337
+ matched_key = phrase_apos_lower
338
+ elif phrase_lower in lookup:
339
+ matched_key = phrase_lower
340
+
341
+ if matched_key:
342
+ replacement = lookup[matched_key]
343
+
344
+ # Preserve capitalization of first word
345
+ if phrase_words[0][0].isupper():
346
+ replacement = replacement[0].upper() + replacement[1:]
347
+
348
+ # Replace first word token with the replacement
349
+ tokens[first_token_idx] = (replacement, 'word')
350
+ # Remove the leading apostrophe if matched via apostrophe-prefixed key
351
+ if matched_key == phrase_apos_lower and first_has_apos:
352
+ tokens.pop(first_token_idx - 1)
353
+ word_indices = [wi - 1 if wi > first_token_idx - 1 else wi for wi in word_indices]
354
+ phrase_word_indices = [wi - 1 for wi in phrase_word_indices]
355
+ # Remove the other matched word tokens and intervening non-word tokens
356
+ for idx in reversed(phrase_word_indices[1:]):
357
+ # Remove any preceding space/punct tokens between this word and the previous one
358
+ while idx > 0 and tokens[idx-1][1] in ('space', 'punct'):
359
+ tokens.pop(idx-1)
360
+ word_indices = [wi - 1 if wi > idx-1 else wi for wi in word_indices]
361
+ idx -= 1
362
+ tokens.pop(idx)
363
+ # Adjust word_indices after removal
364
+ word_indices = [wi - 1 if wi > idx else wi for wi in word_indices]
365
+
366
+ i += word_count # Skip all words in the matched phrase
367
+ multi_word_match = True
368
+ break
369
+
370
+ if multi_word_match:
371
+ break
372
+
373
+ if multi_word_match:
374
+ continue
375
+
376
+ # Handle ma' with MA_FOLLOWERS lookup table
377
+ if word_lower == "ma'" and self.normalize_ma_with_followers:
378
+ # ma' becomes mae
379
+ ma_normalized = "Mae" if word[0].isupper() else "mae"
380
+ tokens[token_idx] = (ma_normalized, 'word')
381
+
382
+ # Check if next word should be normalized (try apostrophe-prefixed form too)
383
+ if next_word:
384
+ next_token_idx = word_indices[i + 1]
385
+ next_has_apos = (next_token_idx > 0
386
+ and tokens[next_token_idx - 1][0] in ("'", "\u2019")
387
+ and tokens[next_token_idx - 1][1] == 'punct')
388
+ next_lookup = "'" + next_word if next_has_apos else next_word
389
+
390
+ if next_lookup in MA_FOLLOWERS:
391
+ normalized_follower = MA_FOLLOWERS[next_lookup]
392
+ tokens[next_token_idx] = (normalized_follower, 'word')
393
+ # Remove the leading apostrophe if present
394
+ if next_has_apos:
395
+ tokens.pop(next_token_idx - 1)
396
+ word_indices = [wi - 1 if wi > next_token_idx - 1 else wi for wi in word_indices]
397
+ i += 2 # Skip both ma' and the follower
398
+ continue
399
+ elif next_word in MA_FOLLOWERS:
400
+ normalized_follower = MA_FOLLOWERS[next_word]
401
+ tokens[next_token_idx] = (normalized_follower, 'word')
402
+ i += 2
403
+ continue
404
+
405
+ i += 1
406
+ continue
407
+
408
+ # Handle 'di with context
409
+ elif word_with_apos_lower == "'di" and self.expand_di_contextual:
410
+ # Check last word of prev token (handles expanded forms like "dw i" from "dwi")
411
+ prev_check = prev_word.split()[-1].rstrip("'") if prev_word else None
412
+ if prev_check and prev_check in DI_WEDI_SAFE_PRECEDERS:
413
+ tokens[token_idx] = ("wedi", 'word')
414
+ # Remove the leading apostrophe token
415
+ tokens.pop(token_idx - 1)
416
+ word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
417
+ else:
418
+ # Keep as-is
419
+ if self.flag_uncertain:
420
+ flag = NormalizationFlag(
421
+ original=word_with_apos,
422
+ normalized=word_with_apos,
423
+ flag_type=FlagType.CONTEXT_UNCERTAIN,
424
+ context=f"...'di not preceded by pronoun",
425
+ position=i,
426
+ suggestion="'di not preceded by pronoun - keeping as-is"
427
+ )
428
+ flags.append(flag)
429
+ i += 1
430
+ continue
431
+
432
+ # Regular word corrections — try apostrophe-prefixed form first
433
+ # (handles tokenizer splitting e.g. ' + na that should match 'na → yna)
434
+ matched = False
435
+ if word_with_apos and word_with_apos in self.corrections:
436
+ tokens[token_idx] = (self.corrections[word_with_apos], 'word')
437
+ tokens.pop(token_idx - 1)
438
+ word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
439
+ matched = True
440
+ elif word_with_apos_lower and word_with_apos_lower in self.corrections_lower:
441
+ replacement = self.corrections_lower[word_with_apos_lower]
442
+ tokens[token_idx] = (replacement, 'word')
443
+ tokens.pop(token_idx - 1)
444
+ word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
445
+ matched = True
446
+ elif word in self.corrections:
447
+ tokens[token_idx] = (self.corrections[word], 'word')
448
+ matched = True
449
+ elif word_lower in self.corrections_lower:
450
+ replacement = self.corrections_lower[word_lower]
451
+ if word[0].isupper():
452
+ replacement = replacement.capitalize()
453
+ tokens[token_idx] = (replacement, 'word')
454
+ matched = True
455
+ # else: keep token as-is
456
+
457
+ i += 1
458
+
459
+ return detokenize(tokens), flags
460
+
461
+ def get_flags_summary(self, flags: List[NormalizationFlag]) -> str:
462
+ """Generate a human-readable summary of flags."""
463
+ if not flags:
464
+ return "No uncertain cases flagged."
465
+
466
+ lines = [f"Found {len(flags)} uncertain case(s):", ""]
467
+ for i, flag in enumerate(flags, 1):
468
+ lines.append(f"{i}. '{flag.original}' at position {flag.position}")
469
+ lines.append(f" Context: {flag.context}")
470
+ lines.append(f" Suggestion: {flag.suggestion}")
471
+ lines.append("")
472
+
473
+ return "\n".join(lines)
474
+
475
+ def describe_config(self) -> str:
476
+ lines = ["Welsh Normalizer Configuration:", "=" * 40]
477
+ settings = [
478
+ ("Remove non-speech tags", self.remove_tags),
479
+ ("Remove asterisks", self.remove_asterisks),
480
+ ("Expand pronouns (nw->nhw)", self.expand_pronouns),
481
+ ("Expand verb contractions (ca'l->cael)", self.expand_verb_contractions),
482
+ ("Expand mae contractions (ma'->mae)", self.expand_mae_contractions),
483
+ ("Normalize ma' with followers (ma' nw->mae nhw)", self.normalize_ma_with_followers),
484
+ ("Expand 'di contextually (dwi 'di->dwi wedi)", self.expand_di_contextual),
485
+ ("Standardize plurals (petha->pethau)", self.standardize_plurals),
486
+ ("Standardize verbs (dechre->dechrau)", self.standardize_verbs),
487
+ ("Standardize adjectives (olaf->ola)", self.standardize_adjectives),
488
+ ("Standardize internal (gwbod->gwybod)", self.standardize_internal),
489
+ ("Standardize spelling", self.standardize_spelling),
490
+ ("Remove hesitations", self.remove_hesitations),
491
+ ("Expand annotations ({saith|7}->7)", self.expand_annotations),
492
+ ("Normalize numbers to digits (for WER)", self.normalize_numbers),
493
+ ("Join hyphenated compounds (for WER)", self.join_hyphenated_compounds),
494
+ ("Lowercase output (for WER)", self.lowercase),
495
+ ("Strip punctuation (for WER)", self.strip_punctuation),
496
+ ("Flag uncertain cases", self.flag_uncertain),
497
+ ]
498
+ for name, enabled in settings:
499
+ status = "ON" if enabled else "OFF"
500
+ lines.append(f" [{status}] {name}")
501
+ return "\n".join(lines)
502
+
503
+ def normalize_srt_file(self, srt_path: str, output_path: Optional[str] = None, include_original: bool = False) -> str:
504
+ """
505
+ Normalize the subtitle text content of an SRT file.
506
+
507
+ Args:
508
+ srt_path: Path to the input SRT file
509
+ output_path: Optional path for the output file. If None, returns normalized content.
510
+ include_original: If True, include original text above normalized text
511
+
512
+ Returns:
513
+ The normalized SRT content as a string
514
+ """
515
+ # Read the SRT file
516
+ with open(srt_path, 'r', encoding='utf-8') as f:
517
+ content = f.read()
518
+
519
+ # Parse SRT format: sequence number, timestamp, subtitle text, blank line
520
+ normalized_lines = []
521
+ lines = content.split('\n')
522
+ i = 0
523
+
524
+ while i < len(lines):
525
+ line = lines[i].strip()
526
+
527
+ # Check if this is a sequence number (digits only)
528
+ if line.isdigit():
529
+ normalized_lines.append(line)
530
+ i += 1
531
+
532
+ # Next line should be timestamp
533
+ if i < len(lines):
534
+ timestamp_line = lines[i].strip()
535
+ normalized_lines.append(timestamp_line)
536
+ i += 1
537
+
538
+ # Collect subtitle text until blank line
539
+ subtitle_lines = []
540
+ while i < len(lines) and lines[i].strip():
541
+ subtitle_lines.append(lines[i])
542
+ i += 1
543
+
544
+ # Normalize the subtitle text
545
+ subtitle_text = '\n'.join(subtitle_lines)
546
+ normalized_text = self.normalize(subtitle_text)
547
+
548
+ # Add original and normalized text
549
+ if include_original:
550
+ normalized_lines.append(subtitle_text)
551
+ normalized_lines.append(normalized_text)
552
+ else:
553
+ normalized_lines.append(normalized_text)
554
+
555
+ # Add blank line separator
556
+ normalized_lines.append('')
557
+ else:
558
+ # Skip empty lines or malformed content
559
+ i += 1
560
+
561
+ # Join all lines
562
+ result = '\n'.join(normalized_lines)
563
+
564
+ # Write to output file if specified
565
+ if output_path:
566
+ with open(output_path, 'w', encoding='utf-8') as f:
567
+ f.write(result)
568
+
569
+ return result
570
+
571
+
572
+ def main():
573
+ import argparse
574
+
575
+ parser = argparse.ArgumentParser(
576
+ description='Welsh text normalizer - normalize text or SRT subtitle files',
577
+ formatter_class=argparse.RawDescriptionHelpFormatter,
578
+ epilog='''
579
+ Examples:
580
+ # Normalize example text
581
+ python welsh_normalizer.py --text "bo' nw'n gwbod"
582
+
583
+ # Normalize an SRT file
584
+ python welsh_normalizer.py --srt input.srt
585
+
586
+ # Normalize an SRT file and save to output file
587
+ python welsh_normalizer.py --srt input.srt --output output.srt
588
+
589
+ # Normalize with original text included above normalized text
590
+ python welsh_normalizer.py --srt input.srt --output output.srt --include-original
591
+ '''
592
+ )
593
+
594
+ # Create mutually exclusive group for input type
595
+ input_group = parser.add_mutually_exclusive_group(required=True)
596
+ input_group.add_argument(
597
+ '--text',
598
+ type=str,
599
+ help='Text to normalize'
600
+ )
601
+ input_group.add_argument(
602
+ '--srt',
603
+ type=str,
604
+ help='Path to SRT file to normalize'
605
+ )
606
+
607
+ parser.add_argument(
608
+ '--output', '-o',
609
+ type=str,
610
+ help='Output file path (only for SRT files)'
611
+ )
612
+
613
+ parser.add_argument(
614
+ '--include-original',
615
+ action='store_true',
616
+ help='Include original text above normalized text in SRT output'
617
+ )
618
+
619
+ parser.add_argument(
620
+ '--show-config',
621
+ action='store_true',
622
+ help='Show normalizer configuration'
623
+ )
624
+
625
+ args = parser.parse_args()
626
+
627
+ # Create normalizer instance
628
+ normalizer = WelshNormalizer()
629
+
630
+ # Show configuration if requested
631
+ if args.show_config:
632
+ print(normalizer.describe_config())
633
+ print()
634
+
635
+ # Process text input
636
+ if args.text:
637
+ if args.output:
638
+ print("Warning: --output is ignored for text input")
639
+
640
+ normalized = normalizer.normalize(args.text)
641
+ print("Original:")
642
+ print(args.text)
643
+ print("\nNormalized:")
644
+ print(normalized)
645
+
646
+ # Process SRT file input
647
+ elif args.srt:
648
+ if args.include_original and not args.srt:
649
+ print("Warning: --include-original only applies to SRT files")
650
+
651
+ try:
652
+ normalized = normalizer.normalize_srt_file(
653
+ args.srt,
654
+ args.output,
655
+ include_original=args.include_original
656
+ )
657
+
658
+ if args.output:
659
+ print(f"Normalized SRT file saved to: {args.output}")
660
+ else:
661
+ print("Normalized SRT content:")
662
+ print(normalized)
663
+
664
+ except FileNotFoundError:
665
+ print(f"Error: File not found: {args.srt}")
666
+ sys.exit(1)
667
+ except Exception as e:
668
+ print(f"Error processing SRT file: {e}")
669
+ sys.exit(1)
670
+
671
+
672
+ if __name__ == '__main__':
673
+ main()