UNPKG

harfizer

Version:

> **Convert numbers, dates, and times into words — in 7+ languages, with style.**

358 lines (328 loc) 10.8 kB
/** * @fileoverview * The EnglishLanguagePlugin class implements the LanguagePlugin interface * and provides methods for converting numbers, dates, and times into their * English textual representation. It handles integer and decimal numbers, * negative values, Gregorian date strings, and time strings (HH:mm). * * Note: The Persian solar calendar is specific to Persian; for English, * the Gregorian calendar is used and dates are formatted as "Month Day, Year". */ import { ConversionOptions, InputNumber, LanguagePlugin } from "../core"; export class EnglishLanguagePlugin implements LanguagePlugin { /** * Default separator for joining parts. */ private static readonly DEFAULT_SEPARATOR: string = " "; /** * The word used for zero in English. */ private static readonly ZERO_WORD: string = "zero"; /** * The word used for representing negative numbers in English. */ private static readonly NEGATIVE_WORD: string = "minus"; /** * Scale units in English for grouping numbers by thousands. */ private static readonly SCALE: string[] = [ "", "thousand", "million", "billion", "trillion", "quadrillion", ]; // Arrays for number conversion: private static readonly UNITS: string[] = [ "", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", ]; private static readonly TEENS: string[] = [ "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", ]; private static readonly TENS: string[] = [ "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", ]; /** * Converts a number less than 1000 into its English textual representation. * * @param {number} n - Number less than 1000. * @returns {string} The English representation. * * Examples: * 7 => "seven" * 15 => "fifteen" * 42 => "forty-two" * 300 => "three hundred" * 456 => "four hundred fifty-six" */ private convertBelowThousand(n: number): string { let result = ""; const hundreds = Math.floor(n / 100); const remainder = n % 100; if (hundreds > 0) { result += EnglishLanguagePlugin.UNITS[hundreds] + " hundred"; } if (remainder > 0) { if (result) { result += " "; } if (remainder < 10) { result += EnglishLanguagePlugin.UNITS[remainder]; } else if (remainder < 20) { result += EnglishLanguagePlugin.TEENS[remainder - 10]; } else { const tens = Math.floor(remainder / 10); const unit = remainder % 10; result += EnglishLanguagePlugin.TENS[tens]; if (unit > 0) { result += "-" + EnglishLanguagePlugin.UNITS[unit]; } } } return result; } /** * Splits a numeric string into groups of three digits (from right to left). * * Example: "1234567" => ["1", "234", "567"] * * @param {string | number} num - The number to be split. * @returns {string[]} An array of three-digit groups. */ private static splitIntoTriples(num: number | string): string[] { let str: string = typeof num === "number" ? num.toString() : num; const groups: string[] = []; while (str.length > 0) { const end = str.length; const start = Math.max(0, end - 3); groups.unshift(str.substring(start, end)); str = str.substring(0, start); } return groups; } /** * Converts a three-digit number (or fewer) into its English textual form. * This function is used internally to process larger numbers. * * @param {InputNumber} num - The three-digit number to convert. * @param {any} [lexicon] - Not used in English conversion. * @param {string} [_separator] - Not used in this method. * @returns {string} The textual representation (e.g. "four hundred fifty-six"). */ public convertTripleToWords( num: InputNumber, lexicon?: any, _separator?: string ): string { const value = typeof num === "bigint" ? Number(num) : parseInt(num.toString(), 10); if (value === 0) return ""; return this.convertBelowThousand(value); } /** * Converts a given number (integer or decimal, possibly negative) into its English textual form. * Handles custom options and converts the fractional part digit-by-digit using "point". * * @param {InputNumber} input - The number to be converted. * @param {ConversionOptions} [options] - Supported options: * - customZeroWord: override the default word for zero. * - customNegativeWord: override the default negative word. * - customSeparator: override the default separator between tokens. * @returns {string} The English textual representation of the number. * @throws {Error} If the input format is invalid or out of allowed range. */ public convertNumber( input: InputNumber, options?: ConversionOptions ): string { const effectiveOptions: ConversionOptions = { ...options }; const zeroWord = effectiveOptions.customZeroWord || EnglishLanguagePlugin.ZERO_WORD; const negativeWord = effectiveOptions.customNegativeWord || EnglishLanguagePlugin.NEGATIVE_WORD; const separator = effectiveOptions.customSeparator || EnglishLanguagePlugin.DEFAULT_SEPARATOR; let rawInput: string = typeof input === "bigint" ? input.toString() : input.toString().trim(); let isNegative = false; if (rawInput.startsWith("-")) { isNegative = true; rawInput = rawInput.slice(1).replace(/[,\s-]/g, ""); } else { rawInput = rawInput.replace(/[,\s-]/g, ""); } if (!/^\d+(\.\d+)?$/.test(rawInput)) { throw new Error("Error: Invalid input format."); } if (rawInput === "0" || rawInput === "0.0") { return zeroWord; } // Separate integer and fractional parts. let integerPart = rawInput; let fractionalPart = ""; const pointIndex = rawInput.indexOf("."); if (pointIndex > -1) { integerPart = rawInput.substring(0, pointIndex); fractionalPart = rawInput.substring(pointIndex + 1); } if (integerPart.length > 66) { throw new Error("Error: Out of range."); } // Break the integer part into triples. const triples: string[] = EnglishLanguagePlugin.splitIntoTriples(integerPart); const wordParts: string[] = []; for (let i = 0; i < triples.length; i++) { const converted = this.convertTripleToWords(triples[i]); if (converted !== "") { const scaleIndex = triples.length - i - 1; let scaleWord = ""; if (scaleIndex > 0) { scaleWord = EnglishLanguagePlugin.SCALE[scaleIndex]; } // If scaleWord exists, append it with a space. wordParts.push(converted + (scaleWord ? " " + scaleWord : "")); } } let result = wordParts.join(separator); // Process fractional part: convert each digit using English words. if (fractionalPart.length > 0) { const digitNames = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", ]; const fracTokens = fractionalPart .split("") .map((d) => digitNames[parseInt(d, 10)]); result += separator + "point" + separator + fracTokens.join(separator); } if (isNegative) { result = negativeWord + separator + result; } return result; } /** * Converts a Gregorian date string (in "YYYY/MM/DD" or "YYYY-MM-DD" format) * into its English textual representation. * The output format is "Month Day, Year", e.g. "April 5, 2023". * * @param {string} dateStr - The date string to be converted. * @param {"jalali" | "gregorian"} [calendar="gregorian"] - Only Gregorian is supported for English. * @returns {string} The English textual form of the date. * @throws {Error} If the format is invalid or if the month is out of range. */ public convertDateToWords( dateStr: string, calendar: "jalali" | "gregorian" = "gregorian" ): string { // For English, we use the Gregorian calendar. const parts = dateStr.split(/[-\/]/); if (parts.length !== 3) { throw new Error( "Invalid date format. Expected 'YYYY/MM/DD' or 'YYYY-MM-DD'." ); } const [yearStr, monthStr, dayStr] = parts; const monthNum = parseInt(monthStr, 10); if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) { throw new Error("Invalid month in date."); } const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const monthName = monthNames[monthNum - 1]; // For English, we can simply convert day and year using convertNumber // but for natural output, we use numeric form for day. const dayNum = parseInt(dayStr, 10); // Convert year using convertNumber to get full words. const yearWords = this.convertNumber(yearStr); return `${monthName} ${dayNum}, ${yearWords}`; } /** * Converts a time string in "HH:mm" format to its English textual representation. * The output format is "It is <hour> o'clock and <minute> minutes". * If minutes are zero, returns "It is <hour> o'clock". * * @param {string} timeStr - The time string in "HH:mm" format. * @returns {string} The English textual representation of the time. * @throws {Error} If the format is invalid or if hours/minutes are out of range. */ public convertTimeToWords(timeStr: string): string { const parts = timeStr.split(":"); if (parts.length !== 2) { throw new Error("Invalid time format. Expected format 'HH:mm'."); } const [hourStr, minuteStr] = parts; const hour = parseInt(hourStr, 10); const minute = parseInt(minuteStr, 10); if (isNaN(hour) || isNaN(minute)) { throw new Error( "Invalid time format. Hours and minutes should be numbers." ); } if (hour < 0 || hour > 23) { throw new Error("Invalid hour value. Hour should be between 0 and 23."); } if (minute < 0 || minute > 59) { throw new Error( "Invalid minute value. Minute should be between 0 and 59." ); } const hourWords = this.convertNumber(hour); const minuteWords = this.convertNumber(minute); if (minute === 0) { return `It is ${hourWords} o'clock`; } else { return `It is ${hourWords} o'clock and ${minuteWords} minutes`; } } }