Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/api/util.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion pythainlp/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"normalize",
"now_reign_year",
"num_to_thaiword",
"num_to_thaiword_float",
"maiyamok",
"rank",
"reign_year_to_ad",
Expand Down Expand Up @@ -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,
Expand Down
151 changes: 135 additions & 16 deletions pythainlp/util/numtoword.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
"",
Expand All @@ -27,6 +27,18 @@
"เก้า",
]
_PLACES: list[str] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน", "ล้าน"]
_DIGITS: list[str] = [
"ศูนย์",

Check failure on line 31 in pythainlp/util/numtoword.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "ศูนย์" 3 times.

See more on https://sonarcloud.io/project/issues?id=PyThaiNLP_pythainlp&issues=AZ-dhMoEs2nL8M0g_NIv&open=AZ-dhMoEs2nL8M0g_NIv&pullRequest=1461
"หนึ่ง",
"สอง",
"สาม",
"สี่",
"ห้า",
"หก",
"เจ็ด",
"แปด",
"เก้า",
]
_EXCEPTIONS: dict[str, str] = {"หนึ่งสิบ": "สิบ", "สองสิบ": "ยี่สิบ", "สิบหนึ่ง": "สิบเอ็ด"}


Expand Down Expand Up @@ -80,6 +92,30 @@
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.

Expand All @@ -98,27 +134,110 @@
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:

Check failure on line 168 in pythainlp/util/numtoword.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=PyThaiNLP_pythainlp&issues=AZ-qjiDd3NaE7bvZA3P1&open=AZ-qjiDd3NaE7bvZA3P1&pullRequest=1461
"""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
67 changes: 67 additions & 0 deletions tests/core/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
normalize,
now_reign_year,
num_to_thaiword,
num_to_thaiword_float,
rank,
reign_year_to_ad,
remove_dangling,
Expand Down Expand Up @@ -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), "หนึ่งร้อยสิบสอง")
Expand All @@ -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)
Expand Down