Skip to content

Commit 7f947ff

Browse files
committed
Fix: Correctly apply custom separator for negative numbers in convertNumber method
1 parent f9b5ab7 commit 7f947ff

3 files changed

Lines changed: 564 additions & 0 deletions

File tree

src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from "./chinesePlugin";
55
export * from "./russianPlugin";
66
export * from "./englishPlugin";
77
export * from "./germanPlugin";
8+
export * from "./spanishPlugin";

src/plugins/spanishPlugin.ts

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
/**
2+
* @fileoverview
3+
* The SpanishLanguagePlugin class implements the LanguagePlugin interface
4+
* and provides methods for converting numbers, dates, and times into their
5+
* Spanish textual representation. It handles integer and decimal numbers,
6+
* negative values, Gregorian date strings, and time strings (HH:mm).
7+
*
8+
* Note: For Spanish, the Gregorian calendar is used, and dates are formatted
9+
* in a natural Spanish style.
10+
*/
11+
12+
import { ConversionOptions, InputNumber, LanguagePlugin } from "../core";
13+
14+
export class SpanishLanguagePlugin implements LanguagePlugin {
15+
/**
16+
* Default separator for joining tokens.
17+
*/
18+
private static readonly DEFAULT_SEPARATOR: string = " ";
19+
20+
/**
21+
* The word for zero in Spanish.
22+
*/
23+
private static readonly ZERO_WORD: string = "cero";
24+
25+
/**
26+
* The word for negative numbers in Spanish.
27+
*/
28+
private static readonly NEGATIVE_WORD: string = "menos";
29+
30+
/**
31+
* Scale units in Spanish for grouping numbers.
32+
* "mil" remains invariable; "millón" is singular and "millones" is plural.
33+
*/
34+
private static readonly SCALE: string[] = [
35+
"",
36+
"mil",
37+
"millón",
38+
"mil millones",
39+
"billón",
40+
"billones",
41+
];
42+
43+
// Lexicons for number conversion.
44+
private static readonly UNITS: string[] = [
45+
"",
46+
"uno",
47+
"dos",
48+
"tres",
49+
"cuatro",
50+
"cinco",
51+
"seis",
52+
"siete",
53+
"ocho",
54+
"nueve",
55+
];
56+
private static readonly TEENS: string[] = [
57+
"diez",
58+
"once",
59+
"doce",
60+
"trece",
61+
"catorce",
62+
"quince",
63+
"dieciséis",
64+
"diecisiete",
65+
"dieciocho",
66+
"diecinueve",
67+
];
68+
private static readonly TENS: string[] = [
69+
"",
70+
"",
71+
"veinte",
72+
"treinta",
73+
"cuarenta",
74+
"cincuenta",
75+
"sesenta",
76+
"setenta",
77+
"ochenta",
78+
"noventa",
79+
];
80+
private static readonly HUNDREDS: string[] = [
81+
"",
82+
"cien",
83+
"doscientos",
84+
"trescientos",
85+
"cuatrocientos",
86+
"quinientos",
87+
"seiscientos",
88+
"setecientos",
89+
"ochocientos",
90+
"novecientos",
91+
];
92+
93+
/**
94+
* Converts a number less than 1000 into its Spanish textual representation.
95+
* Handles numbers between 21 and 29 using a special concatenated form ("veintiuno").
96+
*
97+
* @param n The number to convert.
98+
* @returns The Spanish textual representation of the number.
99+
*/
100+
private convertBelowThousand(n: number): string {
101+
let result = "";
102+
const hundreds = Math.floor(n / 100);
103+
const remainder = n % 100;
104+
let remainderText = "";
105+
106+
// Process remainder (tens and units)
107+
if (remainder > 0) {
108+
if (remainder < 10) {
109+
remainderText = SpanishLanguagePlugin.UNITS[remainder];
110+
} else if (remainder < 20) {
111+
remainderText = SpanishLanguagePlugin.TEENS[remainder - 10];
112+
} else {
113+
const tens = Math.floor(remainder / 10);
114+
const unit = remainder % 10;
115+
if (tens === 2 && unit > 0) {
116+
// Special case for 21-29
117+
const veintiForms = [
118+
"veinte",
119+
"veintiuno",
120+
"veintidós",
121+
"veintitrés",
122+
"veinticuatro",
123+
"veinticinco",
124+
"veintiséis",
125+
"veintisiete",
126+
"veintiocho",
127+
"veintinueve",
128+
];
129+
remainderText = veintiForms[unit];
130+
} else {
131+
remainderText = SpanishLanguagePlugin.TENS[tens];
132+
if (unit > 0) {
133+
remainderText += " y " + SpanishLanguagePlugin.UNITS[unit];
134+
}
135+
}
136+
}
137+
}
138+
139+
// Process hundreds
140+
if (hundreds > 0) {
141+
if (hundreds === 1) {
142+
result = remainder === 0 ? "cien" : "ciento";
143+
} else {
144+
result = SpanishLanguagePlugin.HUNDREDS[hundreds];
145+
}
146+
if (remainderText) {
147+
result += " " + remainderText;
148+
}
149+
} else {
150+
result = remainderText;
151+
}
152+
153+
return result;
154+
}
155+
156+
/**
157+
* Splits a numeric string into groups of three digits (from right to left).
158+
*
159+
* @param num The number or string to split.
160+
* @returns An array of three-digit groups.
161+
*/
162+
private static splitIntoTriples(num: number | string): string[] {
163+
let str: string = typeof num === "number" ? num.toString() : num;
164+
const groups: string[] = [];
165+
while (str.length > 0) {
166+
const end = str.length;
167+
const start = Math.max(0, end - 3);
168+
groups.unshift(str.substring(start, end));
169+
str = str.substring(0, start);
170+
}
171+
return groups;
172+
}
173+
174+
/**
175+
* Converts a three-digit number (or fewer) into its Spanish textual form.
176+
*
177+
* @param num The number to convert.
178+
* @returns The textual representation of the number.
179+
*/
180+
public convertTripleToWords(num: InputNumber): string {
181+
const value =
182+
typeof num === "bigint" ? Number(num) : parseInt(num.toString(), 10);
183+
if (value === 0) return "";
184+
return this.convertBelowThousand(value);
185+
}
186+
187+
/**
188+
* Converts a number (integer or decimal, possibly negative) into its Spanish textual form.
189+
* Handles custom options and converts the fractional part digit-by-digit using "punto".
190+
*
191+
* @param input The number to convert.
192+
* @param options Optional configuration for custom words and separators.
193+
* @returns The Spanish textual representation of the number.
194+
* @throws Error if the input format is invalid or exceeds the allowed range.
195+
*/
196+
public convertNumber(
197+
input: InputNumber,
198+
options?: ConversionOptions
199+
): string {
200+
const effectiveOptions: ConversionOptions = { ...options };
201+
202+
const zeroWord =
203+
effectiveOptions.customZeroWord || SpanishLanguagePlugin.ZERO_WORD;
204+
const negativeWord =
205+
effectiveOptions.customNegativeWord ||
206+
SpanishLanguagePlugin.NEGATIVE_WORD;
207+
const separator =
208+
effectiveOptions.customSeparator ||
209+
SpanishLanguagePlugin.DEFAULT_SEPARATOR;
210+
211+
let rawInput: string =
212+
typeof input === "bigint" ? input.toString() : input.toString().trim();
213+
214+
let isNegative = false;
215+
if (rawInput.startsWith("-")) {
216+
isNegative = true;
217+
rawInput = rawInput.slice(1).replace(/[,\s-]/g, "");
218+
} else {
219+
rawInput = rawInput.replace(/[,\s-]/g, "");
220+
}
221+
222+
if (!/^\d+(\.\d+)?$/.test(rawInput)) {
223+
throw new Error("Error: Invalid input format.");
224+
}
225+
226+
if (rawInput === "0" || rawInput === "0.0") {
227+
return zeroWord;
228+
}
229+
230+
let integerPart = rawInput;
231+
let fractionalPart = "";
232+
const pointIndex = rawInput.indexOf(".");
233+
if (pointIndex > -1) {
234+
integerPart = rawInput.substring(0, pointIndex);
235+
fractionalPart = rawInput.substring(pointIndex + 1);
236+
}
237+
238+
if (integerPart.length > 66) {
239+
throw new Error("Error: Out of range.");
240+
}
241+
242+
const groups: string[] =
243+
SpanishLanguagePlugin.splitIntoTriples(integerPart);
244+
const wordParts: string[] = [];
245+
246+
// Process each group of three digits
247+
for (let i = 0; i < groups.length; i++) {
248+
const converted = this.convertTripleToWords(groups[i]);
249+
if (converted !== "") {
250+
const scaleIndex = groups.length - i - 1;
251+
let scaleWord = "";
252+
if (scaleIndex > 0) {
253+
scaleWord = SpanishLanguagePlugin.SCALE[scaleIndex];
254+
if (scaleWord === "millón" && parseInt(groups[i], 10) > 1) {
255+
scaleWord = "millones"; // fix
256+
}
257+
}
258+
wordParts.push(converted + (scaleWord ? " " + scaleWord : ""));
259+
}
260+
}
261+
262+
let result = wordParts.join(separator);
263+
264+
// Process fractional part
265+
if (fractionalPart.length > 0) {
266+
const digitNames = [
267+
"cero",
268+
"uno",
269+
"dos",
270+
"tres",
271+
"cuatro",
272+
"cinco",
273+
"seis",
274+
"siete",
275+
"ocho",
276+
"nueve",
277+
];
278+
const fracTokens = fractionalPart
279+
.split("")
280+
.map((d) => digitNames[parseInt(d, 10)]);
281+
result += separator + "punto" + separator + fracTokens.join(separator);
282+
}
283+
284+
// Add negative word if applicable
285+
if (isNegative) {
286+
result = negativeWord + (result ? separator + result : "");
287+
}
288+
return result;
289+
}
290+
291+
/**
292+
* Converts a Gregorian date string (in "YYYY/MM/DD" or "YYYY-MM-DD" format)
293+
* into its Spanish textual representation.
294+
*
295+
* @param dateStr The date string to convert.
296+
* @param calendar The calendar type (only "gregorian" supported for Spanish).
297+
* @returns The Spanish textual representation of the date.
298+
* @throws Error if the format is invalid or if the month is out of range.
299+
*/
300+
public convertDateToWords(
301+
dateStr: string,
302+
calendar: "jalali" | "gregorian" = "gregorian"
303+
): string {
304+
const parts = dateStr.split(/[-\/]/);
305+
if (parts.length !== 3) {
306+
throw new Error(
307+
"Invalid date format. Expected 'YYYY/MM/DD' or 'YYYY-MM-DD'."
308+
);
309+
}
310+
const [yearStr, monthStr, dayStr] = parts;
311+
const monthNum = parseInt(monthStr, 10);
312+
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) {
313+
throw new Error("Invalid month in date.");
314+
}
315+
const monthNames = [
316+
"enero",
317+
"febrero",
318+
"marzo",
319+
"abril",
320+
"mayo",
321+
"junio",
322+
"julio",
323+
"agosto",
324+
"septiembre",
325+
"octubre",
326+
"noviembre",
327+
"diciembre",
328+
];
329+
const monthName = monthNames[monthNum - 1];
330+
const dayWords = this.convertNumber(dayStr);
331+
const yearWords = this.convertNumber(yearStr);
332+
return `${dayWords} de ${monthName} de ${yearWords}`;
333+
}
334+
335+
/**
336+
* Converts a time string in "HH:mm" format to its Spanish textual representation.
337+
*
338+
* @param timeStr The time string to convert.
339+
* @returns The Spanish textual representation of the time.
340+
* @throws Error if the format is invalid or if hours/minutes are out of range.
341+
*/
342+
public convertTimeToWords(timeStr: string): string {
343+
const parts = timeStr.split(":");
344+
if (parts.length !== 2) {
345+
throw new Error("Invalid time format. Expected format 'HH:mm'.");
346+
}
347+
const [hourStr, minuteStr] = parts;
348+
let hour = parseInt(hourStr, 10);
349+
const minute = parseInt(minuteStr, 10);
350+
if (isNaN(hour) || isNaN(minute)) {
351+
throw new Error(
352+
"Invalid time format. Hours and minutes should be numbers."
353+
);
354+
}
355+
if (hour < 0 || hour > 23) {
356+
throw new Error("Invalid hour value. Hour should be between 0 and 23.");
357+
}
358+
if (minute < 0 || minute > 59) {
359+
throw new Error(
360+
"Invalid minute value. Minute should be between 0 and 59."
361+
);
362+
}
363+
364+
// Convert hour to 12-hour format for natural phrasing
365+
const hour12 = hour % 12 === 0 ? 12 : hour % 12;
366+
const hourWords = this.convertNumber(hour12);
367+
const minuteWords = this.convertNumber(minute);
368+
369+
if (minute === 0) {
370+
return hour12 === 1
371+
? `Es la una en punto`
372+
: `Son las ${hourWords} en punto`;
373+
} else {
374+
return hour12 === 1
375+
? `Es la una y ${minuteWords} minutos`
376+
: `Son las ${hourWords} y ${minuteWords} minutos`;
377+
}
378+
}
379+
}

0 commit comments

Comments
 (0)