1- /**
2- * Mapping single digit to Vietnamese word
3- */
4- const DIGIT_MAP : readonly string [ ] = [
5- 'không' ,
6- 'một' ,
7- 'hai' ,
8- 'ba' ,
9- 'bốn' ,
10- 'năm' ,
11- 'sáu' ,
12- 'bảy' ,
13- 'tám' ,
14- 'chín' ,
15- ]
16-
17- /**
18- * Get Vietnamese word for a digit
19- */
20- function getDigitWord ( digit : string ) : string {
21- const d = Number ( digit )
22- return DIGIT_MAP [ d ] || ''
23- }
24-
25- /**
26- * Read a 3-digit group
27- * @param group - 3-digit string (can be 1-3 chars)
28- * @param isFirst - is this the first group
29- * @param isBeforeBillion - is this group before a billion group
30- * @returns Vietnamese reading of the group
31- */
32- function readThreeDigits (
33- group : string ,
34- isFirst : boolean ,
35- isBeforeBillion : boolean ,
36- ) : string {
37- const len = group . length
38- const first = len > 2 ? group [ len - 3 ] : '0'
39- const second = len > 1 ? group [ len - 2 ] : '0'
40- const last = group [ len - 1 ] || '0'
41-
42- // All zeros
43- if ( first === '0' && second === '0' && last === '0' ) {
44- return isFirst ? 'không' : ''
45- }
46-
47- let result = ''
48-
49- // First digit (hundreds)
50- // Only processes if the group has at least 3 digits (has a hundreds position)
51- if ( len > 2 ) {
52- result = `${ getDigitWord ( first ) } trăm`
53- }
54-
55- // If the last two digits are zero, skip
56- if ( second === '0' && last === '0' ) {
57- if ( isFirst && isBeforeBillion ) {
58- result += ' nghìn'
59- }
60- return result . trim ( )
61- }
62-
63- // Second digit (tens)
64- // Only process if group has at least 2 digits
65- if ( len > 1 ) {
66- if ( second === '0' ) {
67- result += ' lẻ'
68- } else if ( second === '1' ) {
69- result += ' mười'
70- } else {
71- result += ` ${ getDigitWord ( second ) } mươi`
72- }
73- }
74-
75- // Last digit (ones)
76- if ( len > 1 ) {
77- // Has tens digit - use special rules
78- if ( second !== '0' && second !== '1' && last === '1' ) {
79- result += ' mốt'
80- } else if ( last === '5' && second !== '0' ) {
81- result += ' lăm'
82- } else if ( last !== '0' ) {
83- result += ` ${ getDigitWord ( last ) } `
84- }
85- } else {
86- // No ten digit - just add the last digit
87- result += ` ${ getDigitWord ( last ) } `
88- }
89-
90- if ( isFirst && isBeforeBillion ) {
91- result += ' nghìn'
92- }
93-
94- return result . trim ( )
95- }
96-
97- /**
98- * Split number string into 3-digit groups from right to left
99- */
100- function splitIntoGroups ( numStr : string ) : string [ ] {
101- const groups : string [ ] = [ ]
102- let i = numStr . length
103-
104- while ( i > 0 ) {
105- const start = Math . max ( 0 , i - 3 )
106- groups . unshift ( numStr . slice ( start , i ) )
107- i = start
108- }
109-
110- return groups
111- }
1+ import { calculateGroupTypes , processGroup } from './groups.ts'
2+ import { splitIntoGroups } from './utils.ts'
1123
1134/**
1145 * This is a helper that convert a number to a string like the way a real Vietnamese.
@@ -126,74 +17,13 @@ function splitIntoGroups(numStr: string): string[] {
12617export function readVnNumber ( number : string | number | bigint ) : string {
12718 const numStr = number . toString ( )
12819 const groups = splitIntoGroups ( numStr )
129- const groupCount = groups . length
130-
131- // Map each group index to its type (0=units, 1=thousand, 2=million, 3=billion)
132- // After billion, it cycles: 4->1, 5->2, 6->3, 7->1, etc.
133- const groupTypes : number [ ] = [ ]
134- for ( let i = groupCount - 1 , type = 0 ; i >= 0 ; i -- ) {
135- groupTypes [ i ] = type
136- type ++
137- if ( type === 4 ) type = 1 // cycle back after billion
138- }
20+ const groupTypes = calculateGroupTypes ( groups . length )
13921
14022 const parts : string [ ] = [ ]
141-
142- // Check if all groups after index i are zeros
143- const allFollowingGroupsAreZero = ( index : number ) : boolean => {
144- for ( let j = index + 1 ; j < groupCount ; j ++ ) {
145- const g = groups [ j ]
146- if ( g !== '000' && g !== '00' && g !== '0' ) {
147- return false
148- }
149- }
150- return true
151- }
152-
153- for ( let i = 0 ; i < groupCount ; i ++ ) {
154- const group = groups [ i ]
155- const isFirst = i === 0
156- const type = groupTypes [ i ]
157- const positionFromRight = groupCount - 1 - i
158- const nextGroupType = i + 1 < groupCount ? groupTypes [ i + 1 ] : - 1
159- // beforeBillion only applies to plain Numbers (type 0), not to Thousand/Million/Billion
160- const isBeforeBillion = type === 0 && nextGroupType === 3
161-
162- const groupReading = readThreeDigits ( group , isFirst , isBeforeBillion )
163-
164- if ( groupReading ) {
165- // Check if this is a group in the billion+ range with all following groups being zero
166- const needsBillionSuffix =
167- positionFromRight >= 3 && allFollowingGroupsAreZero ( i )
168-
169- // Add unit suffix based on type (but not if already added by readThreeDigits)
170- if ( type === 3 ) {
171- // Billion
172- // Double "tỷ" only for second billion cycle and beyond (position >= 6)
173- if ( positionFromRight >= 6 && allFollowingGroupsAreZero ( i ) ) {
174- parts . push ( `${ groupReading } tỷ tỷ` )
175- } else {
176- parts . push ( `${ groupReading } tỷ` )
177- }
178- } else if ( type === 2 ) {
179- // Million
180- if ( needsBillionSuffix ) {
181- parts . push ( `${ groupReading } triệu tỷ` )
182- } else {
183- parts . push ( `${ groupReading } triệu` )
184- }
185- } else if ( type === 1 ) {
186- // Thousand
187- if ( needsBillionSuffix ) {
188- parts . push ( `${ groupReading } nghìn tỷ` )
189- } else {
190- parts . push ( `${ groupReading } nghìn` )
191- }
192- } else if ( needsBillionSuffix ) { // Units
193- parts . push ( `${ groupReading } tỷ` )
194- } else {
195- parts . push ( groupReading )
196- }
23+ for ( let i = 0 ; i < groups . length ; i ++ ) {
24+ const result = processGroup ( groups [ i ] , i , groups , groupTypes )
25+ if ( result ) {
26+ parts . push ( result )
19727 }
19828 }
19929
0 commit comments