tanisa
Version:
A utility to convert Malagasy 🇲🇬 numbers, dates, and times into their word representations.
205 lines (172 loc) • 6.76 kB
text/typescript
import { MalagasyNumerals } from './dictionary'
import {
TanisaOptions,
TanisaDateOptions,
TanisaTimeOptions,
LargeNumberUnit,
} from './interface'
import { parseDate, formatShortDate, formatLongDate } from './date'
import { parseTime, formatTime } from './time'
export class Tanisa {
// ─── Public API ────────────────────────────────────────────────────────────
public toWords(number: number | string, options?: TanisaOptions): string {
const ignoreDecimal = options?.ignoreDecimal ?? false
const decimalPlaces = options?.decimalPlaces ?? -1
const numStr = String(number).trim()
// Scientific-notation string inputs (e.g. '1e15') make parseInt unreliable:
// parseInt('1e15', 10) === 1, silently producing the word for 1 instead of an error.
// Number-type inputs are safe because String(1e21) === '1e+21' is caught by the
// MAX_SUPPORTED_INTEGER string-equality guard below.
if (typeof number === 'string' && /[eE]/.test(numStr)) {
throw new TypeError(`Invalid number input: "${number}"`)
}
const [integerPartStr, decimalPartStr] = numStr.split('.')
const integerPartNum = parseInt(integerPartStr || '0', 10)
if (
isNaN(integerPartNum) ||
(decimalPartStr &&
decimalPartStr.length > 0 &&
isNaN(parseInt(decimalPartStr, 10)))
) {
throw new TypeError(`Invalid number input: "${number}"`)
}
// Reject negative numbers. '-0' (no decimal) is intentionally allowed and
// returns 'aotra'. '-0.5' must be rejected: integerPartNum is 0 but the
// fractional part is negative.
if (
numStr.startsWith('-') &&
(integerPartNum !== 0 ||
(decimalPartStr && parseInt(decimalPartStr, 10) > 0))
) {
throw new RangeError('Negative numbers are not supported.')
}
if (
integerPartNum >= MalagasyNumerals.MAX_SUPPORTED_INTEGER ||
integerPartStr == MalagasyNumerals.MAX_SUPPORTED_INTEGER.toString()
) {
throw new RangeError(
`Number ${integerPartNum} exceeds the maximum supported value (${MalagasyNumerals.MAX_SUPPORTED_INTEGER}).`
)
}
const integerWords = this.convertInteger(integerPartNum)
let decimalWords = ''
const processDecimals =
decimalPartStr &&
decimalPartStr.length > 0 &&
!ignoreDecimal &&
decimalPlaces !== 0
if (processDecimals) {
let effectiveDecimalPartStr = decimalPartStr
if (decimalPlaces > 0 && decimalPartStr.length > decimalPlaces) {
effectiveDecimalPartStr = decimalPartStr.substring(0, decimalPlaces)
}
if (parseInt(effectiveDecimalPartStr || '0', 10) > 0) {
let tempDecimalWords = ''
for (let i = 0; i < effectiveDecimalPartStr.length; i++) {
const digit = effectiveDecimalPartStr[i]
if (digit === '0') {
tempDecimalWords += MalagasyNumerals.GLUE_DECIMAL_ZERO
} else {
const remainingDecimal = effectiveDecimalPartStr.substring(i)
tempDecimalWords += this.convertInteger(
parseInt(remainingDecimal, 10)
)
break
}
}
if (tempDecimalWords) {
decimalWords = MalagasyNumerals.GLUE_FAINGO + tempDecimalWords
}
}
}
return integerWords + decimalWords
}
public toDate(
input: string | Date | number,
options?: TanisaDateOptions
): string {
const { year, month, day } = parseDate(input)
if (options?.format === 'long') {
return formatLongDate(year, month, day, (n) => this.toWords(n))
}
return formatShortDate(year, month, day)
}
public toTime(
input: string | Date | number,
options?: TanisaTimeOptions
): string {
const { hours, minutes, seconds } = parseTime(input)
return formatTime(
hours,
minutes,
seconds,
options?.precision ?? 'minutes',
(n) => this.toWords(n)
)
}
// ─── Private number-conversion helpers ─────────────────────────────────────
private convertInteger(num: number): string {
if (num === 0) return MalagasyNumerals.ZERO
for (const unit of MalagasyNumerals.LARGE_NUMBER_UNITS) {
if (num >= unit.threshold) return this.formatLargeNumber(num, unit)
}
return this.convertBelowThousand(num)
}
private formatLargeNumber(num: number, unit: LargeNumberUnit): string {
const multiple = Math.floor(num / unit.threshold)
const remainder = num % unit.threshold
let prefix = ''
// Use prefix only if multiple > 1, or multiple === 1 and unit is above 'arivo'
if (multiple > 1) {
prefix = this.convertInteger(multiple) + ' '
} else if (multiple === 1 && unit.threshold > 1000) {
prefix = MalagasyNumerals.DIGITS[1] + ' '
}
const basePart = prefix + unit.name
if (remainder > 0) {
return (
this.convertInteger(remainder) + MalagasyNumerals.GLUE_SY + basePart
)
}
return basePart
}
private convertBelowThousand(num: number): string {
if (num >= 100) {
const hundredMultiple = Math.floor(num / 100)
const remainder = num % 100
const hundredWord = MalagasyNumerals.HUNDREDS[hundredMultiple]
if (remainder === 0) return hundredWord
const remainderWords = this.convertBelowHundred(remainder)
// Special rule: use "sy" when hundred base ≥ 200 AND remainder ≥ 10
const glue =
hundredMultiple >= 2 && remainder >= 10
? MalagasyNumerals.GLUE_SY
: MalagasyNumerals.GLUE_AMBY
const finalRemainder =
remainder === 1 ? MalagasyNumerals.CUSTOM_ONE : remainderWords
return finalRemainder + glue + hundredWord
}
return this.convertBelowHundred(num)
}
private convertTeens(num: number): string {
const ones = num - 10
const digitWord =
ones === 1 ? MalagasyNumerals.CUSTOM_ONE : MalagasyNumerals.DIGITS[ones]
return digitWord + MalagasyNumerals.GLUE_AMBIN_NY + MalagasyNumerals.TENS[1]
}
private convertBelowHundred(num: number): string {
if (num >= 10) {
if (num > 10 && num < 20) return this.convertTeens(num)
const tenMultiple = Math.floor(num / 10)
const remainder = num % 10
const tenWord = MalagasyNumerals.TENS[tenMultiple]
if (remainder === 0) return tenWord
const digitWord =
remainder === 1
? MalagasyNumerals.CUSTOM_ONE
: MalagasyNumerals.DIGITS[remainder]
return digitWord + MalagasyNumerals.GLUE_AMBY + tenWord
}
return MalagasyNumerals.DIGITS[num]
}
}