File size: 13,309 Bytes
f8b4a6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
#!/usr/bin/env python3
"""
Vietnamese Text Processor for TTS
Handles normalization of numbers, dates, times, currencies, etc.
"""

import re
import unicodedata


# Vietnamese number words
DIGITS = {
    '0': 'không', '1': 'một', '2': 'hai', '3': 'ba', '4': 'bốn',
    '5': 'năm', '6': 'sáu', '7': 'bảy', '8': 'tám', '9': 'chín'
}

TEENS = {
    '10': 'mười', '11': 'mười một', '12': 'mười hai', '13': 'mười ba',
    '14': 'mười bốn', '15': 'mười lăm', '16': 'mười sáu', '17': 'mười bảy',
    '18': 'mười tám', '19': 'mười chín'
}

TENS = {
    '2': 'hai mươi', '3': 'ba mươi', '4': 'bốn mươi', '5': 'năm mươi',
    '6': 'sáu mươi', '7': 'bảy mươi', '8': 'tám mươi', '9': 'chín mươi'
}


def number_to_words(num_str):
    """
    Convert a number string to Vietnamese words.
    Handles numbers from 0 to billions.
    """
    # Remove leading zeros but keep at least one digit
    num_str = num_str.lstrip('0') or '0'
    
    # Handle negative numbers
    if num_str.startswith('-'):
        return 'âm ' + number_to_words(num_str[1:])
    
    # Convert to integer for processing
    try:
        num = int(num_str)
    except ValueError:
        return num_str
    
    if num == 0:
        return 'không'
    
    if num < 10:
        return DIGITS[str(num)]
    
    if num < 20:
        return TEENS[str(num)]
    
    if num < 100:
        tens = num // 10
        units = num % 10
        if units == 0:
            return TENS[str(tens)]
        elif units == 1:
            return TENS[str(tens)] + ' mốt'
        elif units == 4:
            return TENS[str(tens)] + ' tư'
        elif units == 5:
            return TENS[str(tens)] + ' lăm'
        else:
            return TENS[str(tens)] + ' ' + DIGITS[str(units)]
    
    if num < 1000:
        hundreds = num // 100
        remainder = num % 100
        result = DIGITS[str(hundreds)] + ' trăm'
        if remainder == 0:
            return result
        elif remainder < 10:
            return result + ' lẻ ' + DIGITS[str(remainder)]
        else:
            return result + ' ' + number_to_words(str(remainder))
    
    if num < 1000000:
        thousands = num // 1000
        remainder = num % 1000
        result = number_to_words(str(thousands)) + ' nghìn'
        if remainder == 0:
            return result
        elif remainder < 100:
            return result + ' không trăm ' + number_to_words(str(remainder))
        else:
            return result + ' ' + number_to_words(str(remainder))
    
    if num < 1000000000:
        millions = num // 1000000
        remainder = num % 1000000
        result = number_to_words(str(millions)) + ' triệu'
        if remainder == 0:
            return result
        else:
            return result + ' ' + number_to_words(str(remainder))
    
    if num < 1000000000000:
        billions = num // 1000000000
        remainder = num % 1000000000
        result = number_to_words(str(billions)) + ' tỷ'
        if remainder == 0:
            return result
        else:
            return result + ' ' + number_to_words(str(remainder))
    
    # For very large numbers, read digit by digit
    return ' '.join(DIGITS.get(d, d) for d in num_str)


def convert_decimal(text):
    """Convert decimal numbers: 3.14 -> ba phẩy mười bốn"""
    def replace_decimal(match):
        integer_part = match.group(1)
        decimal_part = match.group(2)
        
        integer_words = number_to_words(integer_part)
        
        # Read decimal part as a number
        decimal_words = number_to_words(decimal_part.lstrip('0') or '0')
        
        return f"{integer_words} phẩy {decimal_words}"
    
    # Match decimal numbers: X.Y where Y is 1-2 digits, followed by space or end
    # Avoid matching large numbers like 100.000 (thousand separator)
    text = re.sub(r'(\d+)\.(\d{1,2})(?=\s|$|[^\d])', replace_decimal, text)
    return text


def convert_percentage(text):
    """Convert percentages: 50% -> năm mươi phần trăm"""
    def replace_percent(match):
        num = match.group(1)
        return number_to_words(num) + ' phần trăm'
    
    text = re.sub(r'(\d+(?:[.,]\d+)?)\s*%', replace_percent, text)
    return text


def convert_currency(text):
    """Convert currency amounts"""
    # Vietnamese Dong - be specific to avoid matching "đ" in other words like "độ"
    def replace_vnd(match):
        num = match.group(1).replace('.', '').replace(',', '')
        return number_to_words(num) + ' đồng'
    
    # Only match currency patterns: number followed by currency symbol at word boundary
    text = re.sub(r'(\d+(?:[.,]\d+)*)\s*(?:đồng|VND|vnđ)\b', replace_vnd, text, flags=re.IGNORECASE)
    text = re.sub(r'(\d+(?:[.,]\d+)*)đ(?![a-zà-ỹ])', replace_vnd, text, flags=re.IGNORECASE)
    
    # USD
    def replace_usd(match):
        num = match.group(1).replace('.', '').replace(',', '')
        return number_to_words(num) + ' đô la'
    
    text = re.sub(r'\$\s*(\d+(?:[.,]\d+)*)', replace_usd, text)
    text = re.sub(r'(\d+(?:[.,]\d+)*)\s*(?:USD|\$)', replace_usd, text, flags=re.IGNORECASE)
    
    return text


def convert_time(text):
    """Convert time expressions: 2 giờ 20 phút -> hai giờ hai mươi phút"""
    def replace_time(match):
        hour = match.group(1)
        minute = match.group(2) if match.group(2) else None
        second = match.group(3) if len(match.groups()) > 2 and match.group(3) else None
        
        result = number_to_words(hour) + ' giờ'
        if minute:
            result += ' ' + number_to_words(minute) + ' phút'
        if second:
            result += ' ' + number_to_words(second) + ' giây'
        return result
    
    # HH:MM:SS or HH:MM
    text = re.sub(r'(\d{1,2}):(\d{2})(?::(\d{2}))?', replace_time, text)
    
    # X giờ Y phút
    def replace_time_vn(match):
        hour = match.group(1)
        minute = match.group(2)
        return number_to_words(hour) + ' giờ ' + number_to_words(minute) + ' phút'
    
    text = re.sub(r'(\d+)\s*giờ\s*(\d+)\s*phút', replace_time_vn, text)
    
    # X giờ (without minute)
    def replace_hour(match):
        hour = match.group(1)
        return number_to_words(hour) + ' giờ'
    
    text = re.sub(r'(\d+)\s*giờ(?!\s*\d)', replace_hour, text)
    
    return text


def convert_date(text):
    """Convert date expressions"""
    # DD/MM/YYYY or DD-MM-YYYY
    def replace_date_full(match):
        day = match.group(1)
        month = match.group(2)
        year = match.group(3)
        return f"ngày {number_to_words(day)} tháng {number_to_words(month)} năm {number_to_words(year)}"
    
    # First, replace "Sinh ngày DD/MM/YYYY" pattern to avoid double "ngày"
    text = re.sub(r'(Sinh|sinh)\s+ngày\s+(\d{1,2})[/-](\d{1,2})[/-](\d{4})', 
                  lambda m: f"{m.group(1)} ngày {number_to_words(m.group(2))} tháng {number_to_words(m.group(3))} năm {number_to_words(m.group(4))}", text)
    
    text = re.sub(r'(\d{1,2})[/-](\d{1,2})[/-](\d{4})', replace_date_full, text)
    
    # X tháng Y
    def replace_month_day(match):
        day = match.group(1)
        month = match.group(2)
        return f"ngày {number_to_words(day)} tháng {number_to_words(month)}"
    
    text = re.sub(r'(\d+)\s*tháng\s*(\d+)', replace_month_day, text)
    
    # tháng X (month only)
    def replace_month(match):
        month = match.group(1)
        return 'tháng ' + number_to_words(month)
    
    text = re.sub(r'tháng\s*(\d+)', replace_month, text)
    
    # ngày X
    def replace_day(match):
        day = match.group(1)
        return 'ngày ' + number_to_words(day)
    
    text = re.sub(r'ngày\s*(\d+)', replace_day, text)
    
    return text


def convert_year_range(text):
    """Convert year ranges: 1873-1907 -> một nghìn tám trăm bảy mươi ba đến một nghìn chín trăm lẻ bảy"""
    def replace_year_range(match):
        year1 = match.group(1)
        year2 = match.group(2)
        return number_to_words(year1) + ' đến ' + number_to_words(year2)
    
    text = re.sub(r'(\d{4})\s*[-–—]\s*(\d{4})', replace_year_range, text)
    return text


def convert_ordinal(text):
    """Convert ordinals: thứ 2 -> thứ hai"""
    ordinal_map = {
        '1': 'nhất', '2': 'hai', '3': 'ba', '4': 'tư', '5': 'năm',
        '6': 'sáu', '7': 'bảy', '8': 'tám', '9': 'chín', '10': 'mười'
    }
    
    def replace_ordinal(match):
        prefix = match.group(1)
        num = match.group(2)
        if num in ordinal_map:
            return prefix + ' ' + ordinal_map[num]
        return prefix + ' ' + number_to_words(num)
    
    # thứ X, lần X, bước X, phần X
    text = re.sub(r'(thứ|lần|bước|phần|chương|tập|số)\s*(\d+)', replace_ordinal, text, flags=re.IGNORECASE)
    
    return text


def convert_standalone_numbers(text):
    """Convert remaining standalone numbers to words"""
    def replace_num(match):
        num = match.group(0)
        # Skip if it's part of a word or already processed
        return number_to_words(num)
    
    # Match numbers not followed/preceded by letters
    text = re.sub(r'\b\d+\b', replace_num, text)
    return text


def convert_phone_number(text):
    """Read phone numbers digit by digit"""
    def replace_phone(match):
        phone = match.group(0)
        digits = re.findall(r'\d', phone)
        return ' '.join(DIGITS.get(d, d) for d in digits)
    
    # Vietnamese phone patterns
    text = re.sub(r'0\d{9,10}', replace_phone, text)
    text = re.sub(r'\+84\d{9,10}', replace_phone, text)
    
    return text


def normalize_unicode(text):
    """Normalize Unicode to NFC form"""
    return unicodedata.normalize('NFC', text)


def clean_whitespace(text):
    """Clean up extra whitespace"""
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def remove_special_chars(text):
    """Remove or replace special characters that can't be spoken"""
    # Keep Vietnamese diacritics and common punctuation
    # Remove emojis and special symbols
    
    # Replace common symbols with words
    text = text.replace('&', ' và ')
    text = text.replace('@', ' a còng ')
    text = text.replace('#', ' thăng ')
    text = text.replace('*', '')
    text = text.replace('_', ' ')
    text = text.replace('~', '')
    text = text.replace('`', '')
    text = text.replace('^', '')
    
    # Remove URLs
    text = re.sub(r'https?://\S+', '', text)
    text = re.sub(r'www\.\S+', '', text)
    
    # Remove email addresses
    text = re.sub(r'\S+@\S+\.\S+', '', text)
    
    return text


def normalize_punctuation(text):
    """Normalize punctuation marks"""
    # Normalize quotes
    text = re.sub(r'[""„‟]', '"', text)
    text = re.sub(r"[''‚‛]", "'", text)
    
    # Normalize dashes
    text = re.sub(r'[–—−]', '-', text)
    
    # Normalize ellipsis
    text = re.sub(r'\.{3,}', '...', text)
    text = text.replace('…', '...')
    
    # Remove multiple punctuation
    text = re.sub(r'([!?.]){2,}', r'\1', text)
    
    return text


def process_vietnamese_text(text):
    """
    Main function to process Vietnamese text for TTS.
    Applies all normalization steps in the correct order.
    
    Args:
        text: Raw Vietnamese text
        
    Returns:
        Normalized text suitable for TTS
    """
    # Step 1: Normalize Unicode
    text = normalize_unicode(text)
    
    # Step 2: Remove special characters
    text = remove_special_chars(text)
    
    # Step 3: Normalize punctuation
    text = normalize_punctuation(text)
    
    # Step 4: Convert year ranges (before other number conversions)
    text = convert_year_range(text)
    
    # Step 5: Convert dates
    text = convert_date(text)
    
    # Step 6: Convert times
    text = convert_time(text)
    
    # Step 7: Convert ordinals
    text = convert_ordinal(text)
    
    # Step 8: Convert currency
    text = convert_currency(text)
    
    # Step 9: Convert percentages
    text = convert_percentage(text)
    
    # Step 10: Convert phone numbers
    text = convert_phone_number(text)
    
    # Step 11: Convert decimals (before standalone numbers, after currency)
    text = convert_decimal(text)
    
    # Step 12: Convert remaining standalone numbers
    text = convert_standalone_numbers(text)
    
    # Step 13: Clean whitespace
    text = clean_whitespace(text)
    
    return text


if __name__ == "__main__":
    # Test cases
    test_cases = [
        "Lúc khoảng 2 giờ 20 phút sáng ngày thứ Bảy hay 8 tháng 11",
        "Alfred Jarry 1873-1907 hợp những nhà văn",
        "ông Derringer 44 ly, dí sát đầu tổng thống",
        "Giá sản phẩm là 100.000đ",
        "Tỷ lệ thành công đạt 85%",
        "Họp lúc 14:30",
        "Sinh ngày 15/08/1990",
        "Chương 3: Hành trình mới",
        "Số điện thoại: 0912345678",
        "Nhiệt độ 25.5 độ C",
        "Công ty XYZ có 1500 nhân viên",
    ]
    
    print("=" * 60)
    print("Vietnamese Text Processor Test")
    print("=" * 60)
    
    for text in test_cases:
        processed = process_vietnamese_text(text)
        print(f"\nOriginal: {text}")
        print(f"Processed: {processed}")