|
| 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