diff --git a/docs/api/util.rst b/docs/api/util.rst index 79c0f0b57..6d145c809 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -137,6 +137,12 @@ Modules The `num_to_thaiword` function is a numeral conversion tool for translating Arabic numerals into Thai word form. It is crucial for rendering numbers in a natural Thai textual format. +.. autofunction:: num_to_thaiword_float + :noindex: + + The `num_to_thaiword_float` function converts a floating-point number to Thai text. The integer part uses :func:`num_to_thaiword`, the decimal point is read as "จุด", + and each digit after the decimal is read individually. + .. autofunction:: rank :noindex: diff --git a/pythainlp/util/__init__.py b/pythainlp/util/__init__.py index 76273b953..5c516ae8b 100644 --- a/pythainlp/util/__init__.py +++ b/pythainlp/util/__init__.py @@ -34,6 +34,7 @@ "normalize", "now_reign_year", "num_to_thaiword", + "num_to_thaiword_float", "maiyamok", "rank", "reign_year_to_ad", @@ -117,7 +118,7 @@ remove_zw, reorder_vowels, ) -from pythainlp.util.numtoword import bahttext, num_to_thaiword +from pythainlp.util.numtoword import bahttext, num_to_thaiword, num_to_thaiword_float from pythainlp.util.phoneme import ipa_to_rtgs, nectec_to_ipa, remove_tone_ipa from pythainlp.util.profanity import ( censor_profanity, diff --git a/pythainlp/util/numtoword.py b/pythainlp/util/numtoword.py index 96c29d26b..eed93031b 100644 --- a/pythainlp/util/numtoword.py +++ b/pythainlp/util/numtoword.py @@ -12,7 +12,7 @@ from typing import Optional -__all__: list[str] = ["bahttext", "num_to_thaiword"] +__all__: list[str] = ["bahttext", "num_to_thaiword", "num_to_thaiword_float"] _VALUES: list[str] = [ "", @@ -27,6 +27,18 @@ "เก้า", ] _PLACES: list[str] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน", "ล้าน"] +_DIGITS: list[str] = [ + "ศูนย์", + "หนึ่ง", + "สอง", + "สาม", + "สี่", + "ห้า", + "หก", + "เจ็ด", + "แปด", + "เก้า", +] _EXCEPTIONS: dict[str, str] = {"หนึ่งสิบ": "สิบ", "สองสิบ": "ยี่สิบ", "สิบหนึ่ง": "สิบเอ็ด"} @@ -80,6 +92,30 @@ def bahttext(number: float) -> str: return ret +def _num_to_thaiword_block(num: int) -> str: + """Convert a positive integer < 1,000,000 to Thai text. + + This is the core logic for a single block of up to 6 digits. + """ + if num == 0: + return "" + + output = "" + num_str = str(num) + for place, value in enumerate(list(num_str[::-1])): + if value != "0": + output = _VALUES[int(value)] + _PLACES[place] + output + + for search, replac in _EXCEPTIONS.items(): + output = output.replace(search, replac) + + # เอ็ด rule: trailing หนึ่ง in ones place + if num != 1 and output.endswith("หนึ่ง"): + output = output[: -len("หนึ่ง")] + "เอ็ด" + + return output + + def num_to_thaiword(number: Optional[int]) -> str: """Converts a number to Thai text. @@ -98,27 +134,110 @@ def num_to_thaiword(number: Optional[int]) -> str: if number is None: return "" - output = "" - number_temp = number if number == 0: - output = "ศูนย์" + return "ศูนย์" - number_str = str(abs(number)) - for place, value in enumerate(list(number_str[::-1])): - if place % 6 == 0 and place > 0: - output = _PLACES[6] + output + number_abs = abs(number) + number_str = str(number_abs) - if value != "0": - output = _VALUES[int(value)] + _PLACES[place % 6] + output - - for search, replac in _EXCEPTIONS.items(): - output = output.replace(search, replac) + # Split into groups of 6 digits from right side + groups: list[str] = [] + while number_str: + group = number_str[-6:] + number_str = number_str[:-6] + groups.append(group) - # เอ็ด rule: trailing หนึ่ง in ones place (after any place marker) - if number != 1 and number != -1 and output.endswith("หนึ่ง"): + output = "" + for i in range(len(groups) - 1, -1, -1): + group_num = int(groups[i]) + if group_num > 0: + output += _num_to_thaiword_block(group_num) + if i > 0: + output += "ล้าน" + + # Global เอ็ด rule: trailing หนึ่ง in the full text + if number_abs != 1 and output.endswith("หนึ่ง"): output = output[: -len("หนึ่ง")] + "เอ็ด" - if number_temp < 0: + if number < 0: output = "ลบ" + output return output + + +def num_to_thaiword_float(number: float) -> str: + """Converts a floating-point number to Thai text. + + The integer part is converted using :func:`num_to_thaiword`. + The decimal point is read as "จุด". + Each digit after the decimal is read individually without place descriptions. + + :param float number: a floating-point number to be converted to Thai text + :return: text representing the number in Thai + :rtype: str + :raises TypeError: if *number* is not a numeric type + + :Example: + + >>> from pythainlp.util import num_to_thaiword_float + >>> num_to_thaiword_float(123.45) + 'หนึ่งร้อยยี่สิบสามจุดสี่ห้า' + >>> num_to_thaiword_float(3.14159) + 'สามจุดหนึ่งสี่หนึ่งห้าเก้า' + """ + if not isinstance(number, (int, float)): + raise TypeError( + f"number must be a numeric type, not {type(number).__name__!r}" + ) + + # Reject non-finite floats early (nan/inf), since they cannot be rendered. + if isinstance(number, float): + import math + + if not math.isfinite(number): + raise ValueError("number must be a finite float") + + # Handle whole numbers (including integer types) + if isinstance(number, int) or (isinstance(number, float) and number.is_integer()): + return num_to_thaiword(int(number)) + + # Capture sign for negative floats + is_negative: bool = number < 0 + num_abs: float = abs(number) + num_str: str = str(num_abs) + + # Handle scientific notation (e.g., "1e-05", "1.23e-10") + if "e" in num_str or "E" in num_str: + mantissa, exp_str = num_str.lower().split("e", 1) + exponent: int = int(exp_str) + + if "." in mantissa: + mant_int, mant_frac = mantissa.split(".", 1) + digits = mant_int + mant_frac + decimal_places = len(mant_frac) + else: + digits = mantissa + decimal_places = 0 + + shift = exponent - decimal_places + if shift >= 0: + num_str = digits + ("0" * shift) + else: + pos = len(digits) + shift + if pos > 0: + num_str = digits[:pos] + "." + digits[pos:] + else: + num_str = "0." + ("0" * (-pos)) + digits + if "." not in num_str: + result = num_to_thaiword(int(num_str)) + else: + int_part, dec_part = num_str.split(".") + result = num_to_thaiword(int(int_part)) + result += "จุด" + for digit in dec_part: + result += _DIGITS[int(digit)] + + if is_negative: + result = "ลบ" + result + + return result diff --git a/tests/core/test_util.py b/tests/core/test_util.py index 6aaa4c98a..514eb6753 100644 --- a/tests/core/test_util.py +++ b/tests/core/test_util.py @@ -39,6 +39,7 @@ normalize, now_reign_year, num_to_thaiword, + num_to_thaiword_float, rank, reign_year_to_ad, remove_dangling, @@ -137,6 +138,47 @@ def test_number(self): self.assertEqual(bahttext(0.99), "ศูนย์บาทเก้าสิบเก้าสตางค์") self.assertEqual(bahttext(1.00), "หนึ่งบาทถ้วน") + # num_to_thaiword_float + self.assertEqual(num_to_thaiword_float(123.45), "หนึ่งร้อยยี่สิบสามจุดสี่ห้า") + self.assertEqual(num_to_thaiword_float(0.5), "ศูนย์จุดห้า") + self.assertEqual(num_to_thaiword_float(123.12345), "หนึ่งร้อยยี่สิบสามจุดหนึ่งสองสามสี่ห้า") + self.assertEqual(num_to_thaiword_float(3.14159), "สามจุดหนึ่งสี่หนึ่งห้าเก้า") + self.assertEqual(num_to_thaiword_float(10.01), "สิบจุดศูนย์หนึ่ง") + self.assertEqual(num_to_thaiword_float(1001.001), "หนึ่งพันเอ็ดจุดศูนย์ศูนย์หนึ่ง") + self.assertEqual(num_to_thaiword_float(0.1), "ศูนย์จุดหนึ่ง") + self.assertEqual(num_to_thaiword_float(11.11), "สิบเอ็ดจุดหนึ่งหนึ่ง") + # Whole numbers return integer form + self.assertEqual(num_to_thaiword_float(1.0), "หนึ่ง") + self.assertEqual(num_to_thaiword_float(0.0), "ศูนย์") + self.assertEqual(num_to_thaiword_float(5000000.0), "ห้าล้าน") + # Negative float + self.assertEqual(num_to_thaiword_float(-3.14), "ลบสามจุดหนึ่งสี่") + self.assertEqual(num_to_thaiword_float(-0.5), "ลบศูนย์จุดห้า") + # Very small float (scientific notation) + self.assertEqual(num_to_thaiword_float(1e-5), "ศูนย์จุดศูนย์ศูนย์ศูนย์ศูนย์หนึ่ง") + self.assertEqual( + num_to_thaiword_float(1.23e-10), + "ศูนย์จุดศูนย์ศูนย์ศูนย์ศูนย์ศูนย์ศูนย์ศูนย์ศูนย์ศูนย์หนึ่งสองสาม", + ) + self.assertEqual(num_to_thaiword_float(0.001), "ศูนย์จุดศูนย์ศูนย์หนึ่ง") + self.assertEqual(num_to_thaiword_float(100.0001), "หนึ่งร้อยจุดศูนย์ศูนย์ศูนย์หนึ่ง") + # Many decimal digits + self.assertEqual(num_to_thaiword_float(1.23456789), "หนึ่งจุดสองสามสี่ห้าหกเจ็ดแปดเก้า") + # Large integer with decimal + self.assertEqual(num_to_thaiword_float(1000000000.5), "หนึ่งพันล้านจุดห้า") + # Integer type passed + self.assertEqual(num_to_thaiword_float(42), "สี่สิบสอง") + self.assertEqual(num_to_thaiword_float(-99), "ลบเก้าสิบเก้า") + # Edge case: negative zero (float) + self.assertEqual(num_to_thaiword_float(-0.0), "ศูนย์") + # Edge case: large whole float + self.assertEqual(num_to_thaiword_float(1e9), "หนึ่งพันล้าน") + # Type error + with self.assertRaises(TypeError): + num_to_thaiword_float(None) # type: ignore[arg-type] + with self.assertRaises(TypeError): + num_to_thaiword_float("not a number") # type: ignore[arg-type] + self.assertEqual(num_to_thaiword(None), "") self.assertEqual(num_to_thaiword(0), "ศูนย์") self.assertEqual(num_to_thaiword(112), "หนึ่งร้อยสิบสอง") @@ -147,6 +189,31 @@ def test_number(self): self.assertEqual(num_to_thaiword(101), "หนึ่งร้อยเอ็ด") self.assertEqual(num_to_thaiword(1001), "หนึ่งพันเอ็ด") self.assertEqual(num_to_thaiword(1000001), "หนึ่งล้านเอ็ด") + # เอ็ด rule: millions block with leading digit + self.assertEqual(num_to_thaiword(11000000), "สิบเอ็ดล้าน") + self.assertEqual(num_to_thaiword(21000000), "ยี่สิบเอ็ดล้าน") + self.assertEqual(num_to_thaiword(11000001), "สิบเอ็ดล้านเอ็ด") + self.assertEqual(num_to_thaiword(501741221), "ห้าร้อยเอ็ดล้านเจ็ดแสนสี่หมื่นหนึ่งพันสองร้อยยี่สิบเอ็ด") + self.assertEqual(num_to_thaiword(6001461300), "หกพันเอ็ดล้านสี่แสนหกหมื่นหนึ่งพันสามร้อย") + # Edge cases: simple powers + self.assertEqual(num_to_thaiword(10), "สิบ") + self.assertEqual(num_to_thaiword(20), "ยี่สิบ") + self.assertEqual(num_to_thaiword(100), "หนึ่งร้อย") + self.assertEqual(num_to_thaiword(1000), "หนึ่งพัน") + self.assertEqual(num_to_thaiword(10000), "หนึ่งหมื่น") + self.assertEqual(num_to_thaiword(100000), "หนึ่งแสน") + # Edge cases: full block (all 6 digits) + self.assertEqual(num_to_thaiword(111111), "หนึ่งแสนหนึ่งหมื่นหนึ่งพันหนึ่งร้อยสิบเอ็ด") + # Edge cases: large numbers with multiple ล้าน blocks + self.assertEqual(num_to_thaiword(1000000000), "หนึ่งพันล้าน") + self.assertEqual(num_to_thaiword(1000000000000), "หนึ่งล้านล้าน") + self.assertEqual(num_to_thaiword(10000000), "สิบล้าน") + self.assertEqual(num_to_thaiword(101000000), "หนึ่งร้อยเอ็ดล้าน") + # Edge cases: negative large numbers + self.assertEqual(num_to_thaiword(-1000001), "ลบหนึ่งล้านเอ็ด") + self.assertEqual(num_to_thaiword(-11000000), "ลบสิบเอ็ดล้าน") + # Edge cases: zero-like + self.assertEqual(num_to_thaiword(-0), "ศูนย์") self.assertEqual(thaiword_to_num("ศูนย์"), 0) self.assertEqual(thaiword_to_num("แปด"), 8)