diff --git a/.npmignore b/.npmignore index 960a7e0..e038fe6 100644 --- a/.npmignore +++ b/.npmignore @@ -42,3 +42,6 @@ Thumbs.db # Dependencies node_modules/ + +# Netlify local link state +.netlify/ diff --git a/AGENTS.md b/AGENTS.md index 96623b4..65c2e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,13 +9,21 @@ ### Main Export: `numberstring(n, options)` - Converts a number to words; forgiving: accepts integers, negatives, decimals, numeric strings, BigInt - Returns `string` on success, `false` on invalid input -- Options: `{ cap: 'title'|'upper'|'lower', punc: '!'|'?'|'.', lang: 'es'|'fr'|..., point: 'point' }` +- Options: `cap` (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot), `punc` ('!' '?' '.'), `lang`, `point`, `and` (British), `formal` (zh/ja) ### Named Exports -- `ordinal`, `decimal`, `currency`, `roman`, `parse`, `negative`, `fraction`, `year`, `telephone`, `percent`, `toWords` -- `comma(n)` - Format number with comma separators -- `group(n)` - Get magnitude group (0=ones, 1=thousands, 2=millions, etc.) -- One named export per language (`spanish`, `french`, ...) + +English words: `ordinal`, `nth`, `decimal`, `fraction`, `percent`, `currency`, `year`, `telephone`, `negative`, `parse`, `toWords` + +Spoken and coded: `nato` (aliases `icao`, `military`), `morse` + +Notation: `compact`, `scientific`, `comma`, `group`, `binary`, `octal`, `hex`, `radix`, `bytes`, `bits` + +Other numeral systems (`numerals.js`): `roman`, `greek`, `egyptian`, `babylonian`, `fancy`, `clock` + +Languages (cardinals, non-negative integers): `spanish`, `french`, `german`, `danish`, `chinese`, `hindi`, `russian`, `portuguese`, `japanese`, `korean`, `arabic`, `italian`, `dutch`, `turkish`, `polish`, `swedish`, `indonesian`, `thai`, `norwegian`, `finnish`, `icelandic`. `chinese` and `japanese` accept `{ formal: true }`; `spanish` and `portuguese` accept `{ cap }`. + +Constants: `CAP_STYLES`, `FANCY_STYLE_NAMES` ## Usage Examples @@ -50,10 +58,15 @@ npm run test:coverage # Run with coverage ## Code Architecture -- `index.js` - English core and all public helpers +- `index.js` - English core and all public helpers (ordinal, nato, scientific, bytes, ...) +- `numerals.js` - roman-adjacent systems (egyptian, babylonian, greek), `fancy()` digit styles, `clock()` - `languages/*.js` - one module per language; `test/languages.test.js` is the per-language spot-check table +- `test/extras.test.js` - tests for everything added in 1.2.0 +- `index.d.ts` - hand-written types; check with `npx tsc --noEmit --strict index.d.ts` - `site/` - playground; `scripts/build-site.js` stages the library into `site/lib/` - `archive/` - unmaintained code (old Express server), excluded from tests and lint - Pure functions, no side effects +- **Zero runtime dependencies.** Never add a package to `dependencies`. +- Only use Unicode blocks that macOS renders with system fonts (Mayan numerals and tally marks were dropped for this reason) - Frozen arrays for immutable word lists - Full JSDoc type documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index ce73f68..5de0d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-10-06 + +### Added + +- **Casing styles** - `cap` now also accepts `sentence`, `camel`, `pascal`, `snake`, `kebab` (alias `hyphen`), `constant` (alias `screaming`), and `dot`: `numberstring(123, { cap: 'snake' })` → `one_hundred_twenty_three`. Exported as `CAP_STYLES`. +- **British `and` option** - `numberstring(123, { and: true })` → "one hundred and twenty-three", `numberstring(1001, { and: true })` → "one thousand and one". Also honored by `ordinal()`. +- **`nth(n)`** - Numeric ordinal suffix: `1st`, `22nd`, `113th`. +- **`compact(n, opt)`** - `1.5K`, `2.3B`, `1Sx`, with `digits` and `long` ("1.5 million") options. +- **`fancy(n, style)`** - Digits in Unicode styles: circled ④②, superscript ⁴², subscript, fullwidth, bold, doublestruck 𝟜𝟚, sans, monospace, keycap 4️⃣2️⃣, braille ⠼⠙⠃. +- **Alternative numeral systems** in `numerals.js`: `egyptian()` hieroglyphs (to 9,999,999), `babylonian()` base-60 cuneiform, `greek()` Ionic letters (to 9999). Mayan numerals and tally marks were tried and dropped: no system font on macOS. +- **Financial numerals** - `chinese(n, { formal: true })` → 壹仟零壹 (大写), `japanese(n, { formal: true })` → 壱千壱 (大字). Also via `toWords(n, { lang: 'zh', formal: true })`. +- **`nato(n)`** (aliases `icao`, `military`) - ICAO radiotelephony numerals: `1984` → "wun niner ait fower", `2500` → "too tousand fife hundred", `121.5` → "wun too wun decimal fife". +- **`morse(n)`** - International Morse code digits. +- `telephone(n, { oh: true })` says "oh" for zero. +- **`scientific(n)`** - Exact-mantissa scientific notation: `1984` → "1.984 × 10³", with `caret`, `e`, and `words` formats and a `digits` option. +- **`binary()`, `octal()`, `hex()`, `radix(n, base)`** - Other bases with `prefix`, `upper`, `pad` options. +- **`bytes(n)`** and **`bits(n)`** - `1.5 KB`, `1.5 KiB`, `1.5 Mb`, or "one point five kilobytes". +- **`clock(time)`** - Clock-face emoji for an hour or `H:MM`. +- `fancy()` accepts `emoji` as an alias for `keycap`, and a `clock` style that turns each digit into a clock face (814 → 🕗🕐🕓). +- `roman()` now supports vinculum notation above 3999 (a bar multiplies by 1000, two bars by a million), up to 3,999,999,999. +- Open Graph and Twitter card tags on the playground, with a PNG preview image (`site/og.png`, rendered from `site/og.svg`). +- `comma()` keeps decimals (`1,234,567.89`) and accepts numeric strings. +- Fuzz test (`test/fuzz.test.js`) proves every public function returns a value, never throws, for hostile inputs and options. +- `year()` accepts years beyond 9999 (read as cardinals) and BigInt. +- `bahasa` accepted as an alias for Indonesian. + +### Fixed + +- Playground: ASCII art header restored to the exact index.js block and no longer skewed by per-line centering; a malformed URL hash no longer breaks the page. +- Type declarations: per-language converters no longer advertise a `cap` option they ignore (use `toWords()` for that); Spanish and Portuguese keep it, Chinese and Japanese gain `formal`. + +### Changed + +- Still zero runtime dependencies. + ## [1.1.0] - 2026-10-02 ### Added @@ -14,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Per-language spot-check tests** - `test/languages.test.js` locks in tricky numbers (21, 71, 80, 91, 100, 101, 1000, 1001, 2000, 21000, 1M, 2M, 21M) for all 22 languages. - **TypeScript declarations** - `index.d.ts` covering the default export, every helper, options, and the language functions. - Numbers above `Number.MAX_SAFE_INTEGER` (e.g. `1e21`) are widened to BigInt and converted instead of returning `false`. -- Open Graph and Twitter card tags on the playground. - `npm run site` and `npm run site:build` scripts. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6326f68..9e23735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,10 @@ Key constants: ## Layout - `index.js` - core English conversion plus all public helpers; `numberstring()` is forgiving and delegates to `negative()`, `decimal()`, `toWords()` +- `numerals.js` - alternative numeral systems (egyptian, babylonian, greek) and `fancy()` Unicode digit styles; table-driven, re-exported from index.js. Only add Unicode blocks that macOS renders out of the box (Mayan numerals and tally marks did not) - `languages/` - one module per language, cardinals only, non-negative integers only - `test/languages.test.js` - per-language spot-check table; update expectations when fixing a language -- `site/` - static playground deployed to Netlify (`netlify.toml`); `scripts/build-site.js` copies the library into `site/lib/` +- `site/` - static playground deployed to Netlify (`netlify.toml`); `scripts/build-site.js` copies the library into `site/lib/`. `og.png` is the social preview; after editing `og.svg` run `npm run site:og` to re-render it - `archive/server/` - old Express API, unmaintained, excluded from tests and lint; do not extend it ## Supported Languages @@ -68,15 +69,16 @@ Key constants: ## Features -- Number to words (cardinal) -- Ordinals (1st, 2nd, 3rd) -- Decimals (3.14 → "three point one four") -- Currency ($1.23 → "one dollar and twenty-three cents") -- Fractions (1/2 → "one half") -- Roman numerals (42 → "XLII") -- Negative numbers -- BigInt support up to 10^36 -- Forgiving input: `numberstring(-3.14)`, `numberstring('42')`, `numberstring(42, { lang: 'de' })` all work; invalid input returns `false` +Everything is exported from `index.js`; every function returns `string | false`. + +- English words: `numberstring` (cardinal, forgiving input), `ordinal`, `nth`, `decimal`, `fraction`, `percent`, `currency`, `year`, `telephone` (`oh` option), `negative`, `parse` (words → number) +- 22 languages via `toWords(n, { lang })` or the named exports; `chinese`/`japanese` take `formal` for 大写/大字 +- Spoken and coded: `nato` (`icao`, `military`), `morse` +- Notation: `compact` (1.5K), `scientific` (1.984 × 10³), `comma`, `binary`/`octal`/`hex`/`radix`, `bytes`/`bits` +- Other numeral systems: `roman` (vinculum above 3999), `greek`, `egyptian`, `babylonian`, `fancy` (circled, superscript, doublestruck, keycap/emoji, braille, ...), `clock` +- Options: `cap` casing styles (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot), `punc`, `and` (British), `lang`, `point`, `formal` +- BigInt support up to 10^36; invalid input returns `false` +- **Zero runtime dependencies, always.** Never add a package to `dependencies`. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b04d92d..65b49e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,15 +21,15 @@ We'd love help adding more languages! Here's how: 1. Create a new file in `languages/` (e.g., `pt.js` for Portuguese) 2. Follow the pattern in `languages/en.js` -3. Export a default function that converts numbers to words -4. Add your language to `languages/index.js` -5. Add tests in `test/index.test.js` -6. Update README.md with the new language -7. Submit a PR! +3. Export a default function that converts non-negative integers to words +4. Add your language and its aliases to `languages/index.js`, re-export it from `index.js`, and add it to the `toWords` switch +5. Add a row to the spot-check table in `test/languages.test.js` and a named export in `index.d.ts` +6. Update README.md (feature list, language table, direct exports) and CHANGELOG.md +7. Submit a PR against `dev` ### Pull Request Process -1. Fork the repo and create your branch from `main` +1. Fork the repo and create your branch from `dev` (PRs target `dev`; `master` is the release branch) 2. Run `npm install` to install dependencies 3. Make your changes 4. Run `npm test` to ensure tests pass @@ -37,12 +37,22 @@ We'd love help adding more languages! Here's how: 6. Update documentation if needed 7. Submit your PR! +### Adding a New Conversion + +1. Add the function to `index.js` (or `numerals.js` for glyph-based systems) with JSDoc and an `@example` +2. Return `false` for input you cannot convert; never throw +3. Add it to the export block, `index.d.ts`, the README at-a-glance table and API section, and CHANGELOG.md +4. Add tests in `test/extras.test.js` +5. Add a row to the playground in `site/app.js` if it is worth seeing + ### Code Style -- ES2022+ syntax (const/let, arrow functions, async/await) +- ES2022+ syntax (const/let, arrow functions, template literals) - ESM modules only +- **Zero runtime dependencies.** Dev dependencies only. - Add JSDoc comments for public functions -- Maintain test coverage +- Maintain test coverage (`npm run test:coverage`) +- Unicode output must render with macOS system fonts out of the box ## Code of Conduct diff --git a/README.md b/README.md index 3db9a3c..a150dcb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > Number One Way to Makes Words from Numbers -Transform any number into beautiful words. From `42` to `"forty-two"`, from `1000000` to `"one million"`. Supports **22 languages**, ordinals, currency, Roman numerals, and more! +Transform any number into beautiful words. From `42` to `"forty-two"`, from `1000000` to `"one million"`. Supports **22 languages**, ordinals, currency, Roman numerals, Egyptian hieroglyphs, Babylonian cuneiform, circled digits, and more! **Try it:** [numberstring.netlify.app](https://numberstring.netlify.app) — type a number, see it in 22 languages. Runs entirely in your browser. @@ -20,10 +20,12 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **Zero dependencies** - Lightweight and fast - **22 languages** - English, Spanish, French, German, Danish, Chinese, Hindi, Russian, Portuguese, Japanese, Korean, Arabic, Italian, Dutch, Turkish, Polish, Swedish, Indonesian, Thai, Norwegian, Finnish, Icelandic - **Huge range** - Supports 0 to decillions (10^36) with BigInt -- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers -- **Roman numerals** - Convert to and from Roman numerals +- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers, NATO/ICAO radio numerals, Morse code, scientific notation, binary/hex, byte sizes, clock faces +- **Roman numerals** - Classic and vinculum notation to 3,999,999,999 +- **Ancient and alternative numerals** - Egyptian hieroglyphs, Babylonian cuneiform, Greek letters, Chinese/Japanese financial forms +- **Unicode digit styles** - ④② ⁴² 42 𝟜𝟚 4️⃣2️⃣ ⠼⠙⠃ - **Forgiving input** - Integers, negatives, decimals, numeric strings, BigInt. It just works -- **Well tested** - 660+ tests with 90%+ coverage, including per-language spot checks +- **Well tested** - 780+ tests with 90%+ coverage, per-language spot checks, and a fuzz suite proving nothing ever throws - **Modern ES modules** - Tree-shakeable, with bundled TypeScript declarations ## Installation @@ -43,11 +45,45 @@ numberstring(10n ** 18n); // 'one quintillion' (BigInt!) numberstring(-3.14); // 'negative three point one four' numberstring('1000'); // 'one thousand' numberstring(42, { lang: 'es' }); // 'cuarenta y dos' +numberstring(123, { and: true }); // 'one hundred and twenty-three' numberstring(123, { cap: 'title' }); // 'One Hundred Twenty-Three' ``` ## API Reference +Everything at a glance. Every function returns a `string`, or `false` when the input cannot be converted. + +| Function | Example | Result | +|----------|---------|--------| +| `numberstring(n, opt)` | `numberstring(-3.14)` | `negative three point one four` | +| `toWords(n, { lang })` | `toWords(42, { lang: 'es' })` | `cuarenta y dos` | +| `ordinal(n)` | `ordinal(21)` | `twenty-first` | +| `nth(n)` | `nth(22)` | `22nd` | +| `decimal(n)` | `decimal(3.14)` | `three point one four` | +| `fraction(a, b)` | `fraction(3, 4)` | `three quarters` | +| `percent(n)` | `percent(50)` | `fifty percent` | +| `currency(s)` | `currency('$1.50')` | `one dollar and fifty cents` | +| `year(n)` | `year(1984)` | `nineteen eighty-four` | +| `telephone(s, { oh })` | `telephone(8675309, { oh: true })` | `eight six seven five three oh nine` | +| `nato(n)` / `icao` / `military` | `nato(1984)` | `wun niner ait fower` | +| `morse(n)` | `morse(42)` | `....- ..---` | +| `compact(n)` | `compact(1500000)` | `1.5M` | +| `scientific(n)` | `scientific(1984)` | `1.984 × 10³` | +| `comma(n)` | `comma(1234567)` | `1,234,567` | +| `binary(n)` / `octal` / `hex` / `radix(n, base)` | `hex(255, { prefix: true })` | `0xff` | +| `bytes(n)` / `bits(n)` | `bytes(1536)` | `1.5 KB` | +| `roman(n)` | `roman(1999)` | `MCMXCIX` | +| `greek(n)` | `greek(42)` | `μβʹ` | +| `egyptian(n)` | `egyptian(42)` | `𓎆𓎆𓎆𓎆𓏺𓏺` | +| `babylonian(n)` | `babylonian(42)` | `𒌋𒌋𒌋𒌋𒁹𒁹` | +| `fancy(n, style)` | `fancy(42, 'doublestruck')` | `𝟜𝟚` | +| `clock(time)` | `clock('3:30')` | `🕞` | +| `parse(words)` | `parse('forty-two')` | `42` | +| `negative(n)` | `negative(-42)` | `negative forty-two` | + +Constants: `CAP_STYLES` (casing names for `cap`), `FANCY_STYLE_NAMES` (styles for `fancy`). Each language is also a named export (`spanish`, `french`, ... see below). + + ### Core Functions #### `numberstring(n, [options])` @@ -65,7 +101,19 @@ numberstring(100, { punc: '!' }); // 'one hundred!' numberstring('abc'); // false ``` -Negatives and decimals are English-only; with another `lang` they return `false` rather than falling back to English. +Every word-producing function takes `cap`, and it does more than capitalize: + +```javascript +numberstring(123, { cap: 'camel' }); // 'oneHundredTwentyThree' +numberstring(123, { cap: 'pascal' }); // 'OneHundredTwentyThree' +numberstring(123, { cap: 'snake' }); // 'one_hundred_twenty_three' +numberstring(123, { cap: 'kebab' }); // 'one-hundred-twenty-three' +numberstring(123, { cap: 'constant' }); // 'ONE_HUNDRED_TWENTY_THREE' +numberstring(123, { cap: 'dot' }); // 'one.hundred.twenty.three' +numberstring(123, { cap: 'sentence' }); // 'One hundred twenty-three' +``` + +Negatives and decimals are English-only; with another `lang` they return `false` rather than falling back to English. Pass `and: true` for British style ("one hundred and one", "one thousand and one"). #### `ordinal(n, [options])` @@ -110,7 +158,7 @@ Supported currencies: `$` `€` `£` `¥` `₹` `元` (USD, EUR, GBP, JPY, INR, #### `roman(n, [options])` -Convert to Roman numerals. +Convert to Roman numerals. Above 3999, vinculum notation puts a bar over a group to multiply it by 1000 (two bars for a million), reaching 3,999,999,999. ```javascript import { roman } from 'numberstring'; @@ -118,6 +166,8 @@ import { roman } from 'numberstring'; roman(42); // 'XLII' roman(1999); // 'MCMXCIX' roman(4, { lower: true }); // 'iv' +roman(4000); // 'I̅V̅' +roman(8675309); // 'V̿I̿I̿I̿D̅C̅L̅X̅X̅V̅CCCIX' ``` #### `parse(str)` @@ -132,6 +182,70 @@ parse('one thousand'); // 1000 parse('one quintillion'); // 1000000000000000000n (BigInt) ``` +#### `nth(n)` + +Numeric ordinal suffix. + +```javascript +import { nth } from 'numberstring'; + +nth(1); // '1st' +nth(22); // '22nd' +nth(113); // '113th' +``` + +#### `compact(n, [options])` + +Compact notation. + +```javascript +import { compact } from 'numberstring'; + +compact(1500); // '1.5K' +compact(2300000000); // '2.3B' +compact(999950); // '1M' +compact(1234567, { digits: 2 }); // '1.23M' +compact(1500000, { long: true }); // '1.5 million' +``` + +#### `fancy(n, [style])` + +Digits in a Unicode style: `circled` (default), `superscript`, `subscript`, `fullwidth`, `bold`, `doublestruck`, `sans`, `monospace`, `keycap` (alias `emoji`), `clock`, `braille`. + +```javascript +import { fancy } from 'numberstring'; + +fancy(42); // '④②' +fancy(42, 'superscript'); // '⁴²' +fancy(42, 'doublestruck'); // '𝟜𝟚' +fancy(42, 'keycap'); // '4️⃣2️⃣' +fancy(814, 'clock'); // '🕗🕐🕓' +fancy(-3.5, 'braille'); // '⠼⠤⠉⠨⠑' +``` + +### Ancient and Alternative Numerals + +All render with Unicode glyphs, so they need a font that covers the block (most modern systems do). + +```javascript +import { egyptian, babylonian, greek } from 'numberstring'; + +egyptian(42); // '𓎆𓎆𓎆𓎆𓏺𓏺' additive, 1 to 9,999,999 +babylonian(42); // '𒌋𒌋𒌋𒌋𒁹𒁹' base 60, places separated by spaces +babylonian(3600); // '𒁹 𒑊 𒑊' +greek(42); // 'μβʹ' Ionic letters, 1 to 9999 +greek(1999); // '͵αϡϟθʹ' +``` + +Chinese and Japanese also have the anti-fraud financial forms used on cheques: + +```javascript +import { chinese, japanese } from 'numberstring'; + +chinese(1001, { formal: true }); // '壹仟零壹' (大写) +japanese(1001, { formal: true }); // '壱千壱' (大字) +``` + ### Utility Functions #### `negative(n, [options])` @@ -164,9 +278,10 @@ Convert years to spoken form. ```javascript import { year } from 'numberstring'; -year(1984); // 'nineteen eighty-four' -year(2000); // 'two thousand' -year(2024); // 'twenty twenty-four' +year(1984); // 'nineteen eighty-four' +year(2000); // 'two thousand' +year(2024); // 'twenty twenty-four' +year(8675309); // 'eight million six hundred seventy-five thousand three hundred nine' ``` #### `telephone(phone, [options])` @@ -176,8 +291,86 @@ Convert phone numbers to words. ```javascript import { telephone } from 'numberstring'; -telephone('555-1234'); // 'five five five one two three four' -telephone(8675309); // 'eight six seven five three zero nine' +telephone('555-1234'); // 'five five five one two three four' +telephone(8675309); // 'eight six seven five three zero nine' +telephone(8675309, { oh: true }); // 'eight six seven five three oh nine' +``` + +#### `nato(n, [options])` + +ICAO / NATO radiotelephony numerals, the way pilots and air traffic control read numbers. Also exported as `icao` and `military`. + +```javascript +import { nato } from 'numberstring'; + +nato(1984); // 'wun niner ait fower' +nato(2500); // 'too tousand fife hundred' +nato('121.5'); // 'wun too wun decimal fife' +nato(2500, { digits: true }); // 'too fife zero zero' +``` + +#### `morse(n)` + +International Morse code for the digits. + +```javascript +import { morse } from 'numberstring'; + +morse(42); // '....- ..---' +morse(3.1); // '...-- .-.-.- .----' +``` + +#### `scientific(n, [options])` + +Scientific notation with an exact decimal mantissa. Formats: `unicode` (default), `caret`, `e`, `words`. + +```javascript +import { scientific } from 'numberstring'; + +scientific(1984); // '1.984 × 10³' +scientific(0.00042); // '4.2 × 10⁻⁴' +scientific(1984, { format: 'e' }); // '1.984e3' +scientific(1984, { digits: 3 }); // '1.98 × 10³' +scientific(1984, { format: 'words' }); // 'one point nine eight four times ten to the third' +``` + +#### `binary(n)`, `octal(n)`, `hex(n)`, `radix(n, base)` + +Integers in other bases, 2 to 36. + +```javascript +import { binary, hex, radix } from 'numberstring'; + +binary(42); // '101010' +hex(255, { prefix: true, upper: true }); // '0xFF' +binary(5, { pad: 8 }); // '00000101' +radix(42, 36); // '16' +``` + +#### `bytes(n, [options])` and `bits(n, [options])` + +Human-readable data sizes. Bytes use `KB`/`KiB`, bits use bandwidth-style `kb`/`Mb`. + +```javascript +import { bytes, bits } from 'numberstring'; + +bytes(1536); // '1.5 KB' +bytes(1536, { binary: true }); // '1.5 KiB' +bytes(1536, { long: true }); // 'one point five kilobytes' +bits(1500000); // '1.5 Mb' +bits(1500000, { long: true }); // 'one point five megabits' +``` + +#### `clock(time)` + +Clock-face emoji for an hour or an `H:MM` time, rounded to the half hour. + +```javascript +import { clock } from 'numberstring'; + +clock(3); // '🕒' +clock('3:30'); // '🕞' +clock(15); // '🕒' ``` #### `percent(pct, [options])` @@ -199,7 +392,8 @@ Format a number with comma separators. ```javascript import { comma } from 'numberstring'; -comma(1234567); // '1,234,567' +comma(1234567); // '1,234,567' +comma(1234567.89); // '1,234,567.89' ``` ## Multi-Language Support @@ -260,23 +454,37 @@ toWords(42, { lang: 'is' }); // 'fjörutíu og tveir' | `fi` | Finnish | neljäkymmentäkaksi | | `is` | Icelandic | fjörutíu og tveir | +### Direct language exports + +Each language is also exported by name for tree-shaking: `spanish`, `french`, `german`, `danish`, `chinese`, `hindi`, `russian`, `portuguese`, `japanese`, `korean`, `arabic`, `italian`, `dutch`, `turkish`, `polish`, `swedish`, `indonesian`, `thai`, `norwegian`, `finnish`, `icelandic`. They take a non-negative integer and return the cardinal words. Use `toWords()` or `numberstring()` with `lang` when you want `cap` and the other options. + +```javascript +import { french, japanese } from 'numberstring'; + +french(1984); // 'mille neuf cent quatre-vingt-quatre' +japanese(1984, { formal: true }); // '壱千九百八拾四' +``` + ### Adding a New Language Languages are modular! To add a new language: 1. Create `languages/xx.js` following the pattern in `languages/en.js` 2. Export your conversion function -3. Add to `languages/index.js` -4. Submit a PR! +3. Add to `languages/index.js`, re-export it from `index.js`, and add it to the `toWords` switch +4. Add a row to the table in `test/languages.test.js` and a named export in `index.d.ts` +5. Submit a PR! ## Options | Option | Type | Description | |--------|------|-------------| -| `cap` | `string` | Capitalization: `'title'`, `'upper'`, or `'lower'` | +| `cap` | `string` | Casing: `'title'`, `'upper'`, `'lower'`, `'sentence'`, `'camel'`, `'pascal'`, `'snake'`, `'kebab'`, `'constant'`, `'dot'` | | `punc` | `string` | Punctuation: `'!'`, `'?'`, or `'.'` | | `lang` | `string` | Language code for `numberstring()` and `toWords()` | | `point` | `string` | Word for decimal point (default: `'point'`) | +| `and` | `boolean` | British style: `one hundred and one` | +| `formal` | `boolean` | Chinese/Japanese financial numerals | | `lower` | `boolean` | Lowercase Roman numerals | ## Supported Scales diff --git a/index.d.ts b/index.d.ts index 3554c1f..62aae43 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,7 +3,12 @@ */ /** Capitalization styles */ -export type CapStyle = 'title' | 'upper' | 'lower'; +export type CapStyle = + | 'title' | 'upper' | 'lower' | 'sentence' + | 'camel' | 'pascal' | 'snake' | 'kebab' | 'hyphen' | 'constant' | 'screaming' | 'dot'; + +/** The casing styles the `cap` option accepts */ +export const CAP_STYLES: readonly CapStyle[]; /** Trailing punctuation */ export type Punc = '!' | '?' | '.'; @@ -27,7 +32,7 @@ export type Lang = | 'tr' | 'turkish' | 'türkçe' | 'pl' | 'polish' | 'polski' | 'sv' | 'swedish' | 'svenska' - | 'id' | 'indonesian' | 'bahasa' + | 'id' | 'indonesian' | 'bahasa' | 'bahasa indonesia' | 'th' | 'thai' | 'ไทย' | 'no' | 'norwegian' | 'norsk' | 'fi' | 'finnish' | 'suomi' @@ -35,7 +40,7 @@ export type Lang = | (string & {}); export interface Options { - /** Capitalization: 'title', 'upper', or 'lower' */ + /** Casing: title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot */ cap?: CapStyle; /** Trailing punctuation: '!', '?', or '.' */ punc?: Punc; @@ -43,6 +48,48 @@ export interface Options { lang?: Lang; /** Word for the decimal point (default 'point') */ point?: string; + /** British style: 'one hundred and twenty-three', 'one thousand and one' */ + and?: boolean; + /** Chinese/Japanese only: financial 大写 / 大字 numerals (壹贰叁, 壱弐参) */ + formal?: boolean; +} + +export interface CompactOptions { + /** Maximum decimal places (default 1, max 6) */ + digits?: number; + /** Spell the scale word: '1.5 million' instead of '1.5M' */ + long?: boolean; +} + + +/** Unicode digit styles accepted by fancy() */ +export type FancyStyle = + | 'circled' | 'superscript' | 'subscript' | 'fullwidth' | 'bold' + | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'emoji' | 'clock' | 'braille'; + +export interface ScientificOptions extends Pick { + /** Maximum significant digits, rounds half up (default 12) */ + digits?: number; + /** 'unicode' (1.984 × 10³), 'caret' (1.984 × 10^3), 'e' (1.984e3), or 'words' */ + format?: 'unicode' | 'caret' | 'e' | 'words'; +} + +export interface RadixOptions { + /** Add 0b / 0o / 0x for bases 2, 8, 16 */ + prefix?: boolean; + /** Uppercase letter digits */ + upper?: boolean; + /** Left-pad with zeros to this many digits */ + pad?: number; +} + +export interface BytesOptions { + /** Use 1024 steps and KiB/MiB units */ + binary?: boolean; + /** Maximum decimal places (default 1) */ + digits?: number; + /** Spell it out: 'one point five kilobytes' / 'one point five megabits' */ + long?: boolean; } export interface CurrencyOptions extends Pick { @@ -75,10 +122,33 @@ declare function numberstring(n: Numeric, opt?: Options): Result; export default numberstring; /** Convert to words in any supported language (non-negative integers) */ -export function toWords(n: number | bigint, opt?: Pick): Result; +export function toWords(n: number | bigint, opt?: Pick): Result; /** Ordinal words: 1 → 'first', 21 → 'twenty-first' */ -export function ordinal(n: number | bigint, opt?: Pick): Result; +export function ordinal(n: number | bigint, opt?: Pick): Result; + +/** Numeric ordinal suffix: 1 → '1st', 22 → '22nd', 113 → '113th' */ +export function nth(n: Numeric): string | false; + +/** Compact notation: 1500 → '1.5K', 2300000000 → '2.3B' */ +export function compact(n: Numeric, opt?: CompactOptions): string | false; + +/** Digits in a Unicode style: fancy(42) → '④②', fancy(42, 'superscript') → '⁴²' */ +export function fancy(n: Numeric, style?: FancyStyle): string | false; + +/** The style names fancy() accepts */ +export const FANCY_STYLE_NAMES: readonly FancyStyle[]; + +/** Egyptian hieroglyphic numerals, 1 to 9,999,999 */ +export function egyptian(n: Numeric): string | false; + +/** Babylonian base-60 cuneiform numerals */ +export function babylonian(n: Numeric): string | false; + +/** Greek Ionic alphabetic numerals, 1 to 9999 */ +export function greek(n: Numeric): string | false; + + /** Decimal words: 3.14 → 'three point one four' */ export function decimal(n: number | string, opt?: Pick): Result; @@ -86,7 +156,7 @@ export function decimal(n: number | string, opt?: Pick /** Currency words: '$123.45' → 'one hundred twenty-three dollars and forty-five cents' */ export function currency(amount: number | string, opt?: CurrencyOptions): Result; -/** Roman numerals for 1–3999: 42 → 'XLII' */ +/** Roman numerals for 1–3,999,999,999: 42 → 'XLII'; above 3999 uses vinculum bars (4000 → 'I̅V̅') */ export function roman(n: number, opt?: RomanOptions): Result; /** Parse English words back to a number: 'forty-two' → 42. Returns BigInt above the safe integer range. */ @@ -98,11 +168,52 @@ export function negative(n: number | bigint, opt?: Pick): Result /** Fraction words: (1, 2) → 'one half', (3, 4) → 'three quarters' */ export function fraction(numerator: number, denominator: number, opt?: Pick): Result; -/** Year as spoken: 1984 → 'nineteen eighty-four' */ -export function year(y: number, opt?: Pick): Result; +/** Year as spoken: 1984 → 'nineteen eighty-four'; beyond 9999 reads as a cardinal */ +export function year(y: number | bigint, opt?: Pick): Result; + +export interface TelephoneOptions extends Pick { + /** Say 'oh' instead of 'zero' */ + oh?: boolean; +} /** Digits read individually: '555-1234' → 'five five five one two three four' */ -export function telephone(phone: number | string, opt?: Pick): Result; +export function telephone(phone: number | string, opt?: TelephoneOptions): Result; + +export interface NatoOptions extends Pick { + /** Always read digit by digit, even round hundreds and thousands */ + digits?: boolean; +} + +/** ICAO / NATO radiotelephony numerals: 1984 → 'wun niner ait fower', 2500 → 'too tousand fife hundred' */ +export function nato(n: Numeric, opt?: NatoOptions): Result; +/** Alias of nato() */ +export function icao(n: Numeric, opt?: NatoOptions): Result; +/** Alias of nato() */ +export function military(n: Numeric, opt?: NatoOptions): Result; + +/** International Morse code digits: 42 → '....- ..---' */ +export function morse(n: Numeric): string | false; + +/** Scientific notation with an exact mantissa: 1984 → '1.984 × 10³' */ +export function scientific(n: Numeric, opt?: ScientificOptions): string | false; + +/** Integer in another base, 2 to 36: radix(42, 16) → '2a' */ +export function radix(n: Numeric, base?: number, opt?: RadixOptions): string | false; +/** Binary: 42 → '101010' */ +export function binary(n: Numeric, opt?: RadixOptions): string | false; +/** Octal: 42 → '52' */ +export function octal(n: Numeric, opt?: RadixOptions): string | false; +/** Hexadecimal: 42 → '2a' */ +export function hex(n: Numeric, opt?: RadixOptions): string | false; + +/** Human-readable byte sizes: 1536 → '1.5 KB' */ +export function bytes(n: Numeric, opt?: BytesOptions): string | false; + +/** Human-readable bit counts: 1500000 → '1.5 Mb' */ +export function bits(n: Numeric, opt?: BytesOptions): string | false; + +/** Clock-face emoji for an hour (0-24) or 'H:MM': clock('3:30') → '🕞' */ +export function clock(time: number | string): string | false; /** Percent words: 50 → 'fifty percent' */ export function percent(pct: number | string, opt?: Pick): Result; @@ -113,18 +224,24 @@ export function comma(n: number | bigint): string | false; /** Magnitude group: 0 = ones, 1 = thousands, 2 = millions, ... */ export function group(n: number | bigint): number; -/** A single-language converter for non-negative integers */ -export type LanguageConverter = (n: number | bigint, opt?: Pick) => Result; +/** A single-language converter for non-negative integers. Use toWords() for `cap`. */ +export type LanguageConverter = (n: number | bigint) => Result; + +/** Spanish and Portuguese also accept `cap` directly */ +export type LanguageConverterWithCap = (n: number | bigint, opt?: Pick) => Result; + +/** Chinese and Japanese accept `formal` for 大写 / 大字 numerals */ +export type LanguageConverterWithFormal = (n: number | bigint, opt?: Pick) => Result; -export const spanish: LanguageConverter; +export const spanish: LanguageConverterWithCap; export const french: LanguageConverter; export const german: LanguageConverter; export const danish: LanguageConverter; -export const chinese: LanguageConverter; +export const chinese: LanguageConverterWithFormal; export const hindi: LanguageConverter; export const russian: LanguageConverter; -export const portuguese: LanguageConverter; -export const japanese: LanguageConverter; +export const portuguese: LanguageConverterWithCap; +export const japanese: LanguageConverterWithFormal; export const korean: LanguageConverter; export const arabic: LanguageConverter; export const italian: LanguageConverter; diff --git a/index.js b/index.js index e15680b..6536f5b 100644 --- a/index.js +++ b/index.js @@ -21,8 +21,12 @@ // Import language functions import { english, spanish, french, german, danish, chinese, hindi, russian, portuguese, japanese, korean, arabic, italian, dutch, turkish, polish, swedish, indonesian, thai, norwegian, finnish, icelandic, LANGUAGES } from './languages/index.js'; -// Re-export language functions +import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock } from './numerals.js'; + +// Re-export language functions and alternative numeral systems export { spanish, french, german, danish, chinese, hindi, russian, portuguese, japanese, korean, arabic, italian, dutch, turkish, polish, swedish, indonesian, thai, norwegian, finnish, icelandic }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; +export { CAP_STYLES }; // ============================================================================ // CONSTANTS @@ -93,7 +97,11 @@ const MAX_VALUE = 10n ** 36n - 1n; // HELPER FUNCTIONS // ============================================================================ -const group = (n) => Math.ceil(n.toString().length / 3) - 1; +const group = (n) => { + if (typeof n !== 'number' && typeof n !== 'bigint') return false; + if (typeof n === 'number' && !Number.isFinite(n)) return false; + return Math.ceil((n < 0 ? -n : n).toString().length / 3) - 1; +}; const power = (g) => 10n ** BigInt(g * 3); const segment = (n, g) => n % power(g + 1); const hundment = (n, g) => Number(segment(n, g) / power(g)); @@ -113,7 +121,18 @@ const ten = (n) => { return `${TENS[Math.floor(n / 10)]} `; }; +/** Casing styles accepted by the `cap` option */ +const CAP_STYLES = Object.freeze(['title', 'upper', 'lower', 'sentence', 'camel', 'pascal', 'snake', 'kebab', 'hyphen', 'constant', 'screaming', 'dot']); + +const capFirst = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + +/** + * Apply a casing style. Word-joining styles split on spaces and hyphens: + * 'one hundred twenty-three' → camel 'oneHundredTwentyThree', snake + * 'one_hundred_twenty_three', kebab 'one-hundred-twenty-three'. + */ const cap = (str, style) => { + const words = () => str.split(/[\s-]+/).filter(Boolean); switch (style) { case 'title': return str.replace(/\w([^-\s]*)/g, (txt) => @@ -123,6 +142,22 @@ const cap = (str, style) => { return str.toUpperCase(); case 'lower': return str.toLowerCase(); + case 'sentence': + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + case 'camel': + return words().map((w, i) => (i === 0 ? w.toLowerCase() : capFirst(w))).join(''); + case 'pascal': + return words().map(capFirst).join(''); + case 'snake': + return words().map((w) => w.toLowerCase()).join('_'); + case 'kebab': + case 'hyphen': + return words().map((w) => w.toLowerCase()).join('-'); + case 'constant': + case 'screaming': + return words().map((w) => w.toUpperCase()).join('_'); + case 'dot': + return words().map((w) => w.toLowerCase()).join('.'); default: return str; } @@ -138,8 +173,18 @@ const comma = (n) => { if (typeof n === 'bigint') { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } - if (isNaN(n)) return false; - return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + const [intPart, fracPart] = n.toString().split('.'); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return fracPart === undefined ? grouped : `${grouped}.${fracPart}`; + } + if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) { + const [intPart, fracPart] = n.trim().split('.'); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return fracPart === undefined ? grouped : `${grouped}.${fracPart}`; + } + return false; }; // ============================================================================ @@ -151,6 +196,7 @@ const comma = (n) => { * Internal: the public `string()` normalizes input and delegates here. * @param {number|bigint} n - The number to convert (0 to 10^36-1) * @param {Object} [opt] - Options object + * @param {boolean} [opt.and] - British "and" after hundreds / before a final group under 100 * @returns {string|false} The word representation or false if invalid */ const cardinal = (n, opt) => { @@ -169,15 +215,20 @@ const cardinal = (n, opt) => { let s = ''; + const useAnd = opt?.and === true; + if (num === 0n) { s = 'zero'; } else { for (let i = group(num); i >= 0; i--) { - s += hundred(hundment(num, i)); - s += ten(tenment(num, i)); - if (hundment(num, i) > 0) { - s += `${ILLIONS[i]} `; - } + const h = hundment(num, i); + if (h === 0) continue; + const t = tenment(num, i); + s += hundred(h); + // British style: "one hundred and one", "one thousand and one" + if (useAnd && t > 0 && (h >= 100 || (i === 0 && s))) s += 'and '; + s += ten(t); + s += `${ILLIONS[i]} `; } } @@ -200,14 +251,16 @@ const cardinal = (n, opt) => { * * @param {number|bigint|string} n - The number to convert * @param {Object} [opt] - Options object - * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' + * @param {string} [opt.cap] - Casing: 'title', 'upper', 'lower', 'sentence', 'camel', 'pascal', 'snake', 'kebab', 'constant', 'dot' * @param {string} [opt.punc] - Punctuation: '!', '?', or '.' * @param {string} [opt.lang] - Language code (default 'en') * @param {string} [opt.point] - Word for the decimal point (default 'point') + * @param {boolean} [opt.and] - British style: 'one hundred and twenty-three' * @returns {string|false} The word representation or false if invalid * * @example * numberstring(42) // 'forty-two' + * numberstring(123, { and: true }) // 'one hundred and twenty-three' * numberstring(-5) // 'negative five' * numberstring(3.14) // 'three point one four' * numberstring('1000') // 'one thousand' @@ -217,7 +270,7 @@ const string = (n, opt) => { let value = n; // Delegates apply `cap` themselves; `punc` is applied once in finish() const inner = opt ? { ...opt, punc: undefined } : opt; - const lang = opt?.lang?.toLowerCase(); + const lang = typeof opt?.lang === 'string' ? opt.lang.toLowerCase() : undefined; const foreign = Boolean(lang && LANGUAGES[lang] && LANGUAGES[lang] !== 'english'); if (typeof value === 'string') { @@ -289,7 +342,7 @@ const ordinal = (n, opt) => { s = `${TENS[tensDigit]}-${ORDINAL_ONES[onesDigit]}`; } } else { - const base = cardinal(num); + const base = cardinal(num, { and: opt?.and }); if (!base) return false; const parts = base.split(' '); @@ -358,7 +411,7 @@ const decimal = (n, opt) => { const intDigits = intPart || '0'; const intNum = intDigits.length <= 15 ? parseInt(intDigits, 10) : BigInt(intDigits); - const intWords = cardinal(intNum); + const intWords = cardinal(intNum, { and: opt?.and }); if (intWords === false) return false; let result = isNegative ? 'negative ' : ''; @@ -446,19 +499,52 @@ const currency = (amount, opt) => { // ROMAN NUMERAL FUNCTION // ============================================================================ -const roman = (n, opt) => { - if (typeof n !== 'number' || isNaN(n) || !Number.isInteger(n)) return false; - if (n < 1 || n > 3999) return false; - +/** Classic Roman numerals for 1-3999 */ +const romanBase = (n) => { let result = ''; let remaining = n; - for (const [value, numeral] of ROMAN_VALUES) { while (remaining >= value) { result += numeral; remaining -= value; } } + return result; +}; + +/** Combining marks for vinculum notation: one bar = x1000, two bars = x1000000 */ +const ROMAN_BARS = Object.freeze(['', '\u0305', '\u033F']); + +/** Maximum value expressible with a double vinculum (3,999,999,999) */ +const ROMAN_MAX = 3999999999; + +/** + * Convert to Roman numerals. Classic numerals cover 1-3999; above that, + * vinculum notation places a bar over a group to multiply it by 1000, so + * 4000 is I̅V̅ and 8675309 is V̿I̿I̿I̿D̅C̅L̅X̅X̅V̅CCCIX. + * @param {number} n - Integer from 1 to 3,999,999,999 + * @param {Object} [opt] - Options object + * @param {boolean} [opt.lower] - Return lowercase numerals + * @returns {string|false} The Roman numeral or false if out of range + */ +const roman = (n, opt) => { + if (typeof n !== 'number' || isNaN(n) || !Number.isInteger(n)) return false; + if (n < 1 || n > ROMAN_MAX) return false; + + let result = ''; + let remaining = n; + let level = 0; + + while (remaining > 0) { + // The topmost group keeps the classic form up to 3999; lower groups are 0-999 + const groupValue = remaining < 4000 ? remaining : remaining % 1000; + const bar = ROMAN_BARS[level]; + const letters = romanBase(groupValue); + const barred = bar ? [...letters].map((ch) => ch + bar).join('') : letters; + result = barred + result; + remaining = (remaining - groupValue) / 1000; + level++; + } return opt?.lower ? result.toLowerCase() : result; }; @@ -534,27 +620,436 @@ const parse = (str) => { }; // ============================================================================ -// ADDITIONAL UTILITY FUNCTIONS +// NTH (numeric ordinal suffix) // ============================================================================ -const negative = (n, opt) => { - if (typeof n === 'bigint') { - if (n >= 0n) return cardinal(n, opt); - const result = cardinal(-n, opt); - if (result === false) return false; - let s = `negative ${result}`; - if (opt?.cap) s = cap(s, opt.cap); - return s; +/** + * Append the English ordinal suffix to a number: 1st, 2nd, 3rd, 4th, 11th, 112th. + * @param {number|bigint|string} n - Integer (negatives keep their sign) + * @returns {string|false} + * + * @example + * nth(1) // '1st' + * nth(22) // '22nd' + * nth(113) // '113th' + */ +const nth = (n) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && Math.abs(n) <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^-?\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + + const abs = value < 0n ? -value : value; + const mod100 = Number(abs % 100n); + const mod10 = Number(abs % 10n); + let suffix = 'th'; + if (mod100 < 11 || mod100 > 13) { + if (mod10 === 1) suffix = 'st'; + else if (mod10 === 2) suffix = 'nd'; + else if (mod10 === 3) suffix = 'rd'; + } + return `${value}${suffix}`; +}; + +// ============================================================================ +// COMPACT (1.5K, 2.3M) +// ============================================================================ + +/** Short suffixes aligned with ILLIONS: thousand, million, billion, ... */ +const COMPACT_SUFFIXES = Object.freeze(['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc']); + +/** + * Compact notation: 1500 → '1.5K', 2300000 → '2.3M'. + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {number} [opt.digits=1] - Maximum decimal places + * @param {boolean} [opt.long] - Spell the scale: '1.5 thousand' + * @returns {string|false} + * + * @example + * compact(1500) // '1.5K' + * compact(2300000000) // '2.3B' + * compact(999950) // '1M' + * compact(1500000, { long: true }) // '1.5 million' + */ +const compact = (n, opt) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + const negative = str.startsWith('-'); + if (negative) str = str.slice(1); + const [rawInt, fracPart = ''] = str.split('.'); + // Leading zeros carry no magnitude: '0001000' is 1000 + const intPart = rawInt.replace(/^0+(?=\d)/, ''); + const digits = Number.isFinite(opt?.digits) ? Math.max(0, Math.min(Math.trunc(opt.digits), 6)) : 1; + + if (intPart.length < 4) { + const small = Number(`${intPart}.${fracPart || '0'}`); + const rounded = Number(small.toFixed(digits)); + // 999.99 rounds up to 1000, which belongs in the next scale + if (rounded < 1000) return `${negative ? '-' : ''}${rounded}`; + return `${negative ? '-' : ''}1${opt?.long ? ` ${ILLIONS[1]}` : COMPACT_SUFFIXES[1]}`; + } + + let g = Math.floor((intPart.length - 1) / 3); + if (g >= COMPACT_SUFFIXES.length) return false; + // Value / 10^(3g) as a float; precision loss is irrelevant at <= 6 decimals + const scaled = Number(`${intPart.slice(0, intPart.length - 3 * g)}.${intPart.slice(intPart.length - 3 * g)}${fracPart}`); + let value = Number(scaled.toFixed(digits)); + // Rounding can carry into the next scale; at the last scale keep 1000Dc + if (value >= 1000 && g + 1 < COMPACT_SUFFIXES.length) { + g++; + value = Number((value / 1000).toFixed(digits)); + } + + const scale = opt?.long ? ` ${ILLIONS[g]}` : COMPACT_SUFFIXES[g]; + return `${negative ? '-' : ''}${value}${scale}`; +}; + +// ============================================================================ +// NATO / ICAO PHONETIC NUMERALS +// ============================================================================ + +/** ICAO radiotelephony pronunciations for the digits */ +const NATO_DIGITS = Object.freeze(['zero', 'wun', 'too', 'tree', 'fower', 'fife', 'six', 'seven', 'ait', 'niner']); + +/** + * NATO / ICAO radiotelephony numerals. Digits are read one at a time + * (1984 → 'wun niner ait fower'); per ICAO, whole hundreds and thousands + * are read with 'hundred' and 'tousand' (2500 → 'too tousand fife hundred'). + * A decimal point is 'decimal', a negative sign 'minus'. + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {boolean} [opt.digits] - Always read digit by digit, even round numbers + * @param {string} [opt.cap] - Capitalization: 'title', 'upper', or 'lower' + * @returns {string|false} + * + * @example + * nato(1984) // 'wun niner ait fower' + * nato(2500) // 'too tousand fife hundred' + * nato(3.14) // 'tree decimal wun fower' + */ +const nato = (n, opt) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + const words = []; + if (str.startsWith('-')) { + words.push('minus'); + str = str.slice(1); + } + const [intPart, fracPart] = str.split('.'); + const spell = (digits) => [...digits].map((d) => NATO_DIGITS[Number(d)]); + + // Round hundreds / thousands: "fife hundred", "wun tousand", "too fife tousand" + const roundMatch = !opt?.digits && !fracPart && intPart.match(/^(\d{1,2})(\d?)(00)$/); + if (roundMatch && !intPart.startsWith('0') && intPart.length >= 3 && intPart.length <= 5) { + const thousands = intPart.slice(0, -3); + const hundredsDigit = intPart.slice(-3, -2); + if (thousands) words.push(...spell(thousands), 'tousand'); + if (hundredsDigit !== '0') words.push(NATO_DIGITS[Number(hundredsDigit)], 'hundred'); + } else { + words.push(...spell(intPart)); + if (fracPart) words.push('decimal', ...spell(fracPart)); + } + + let result = words.join(' '); + if (opt?.cap) result = cap(result, opt.cap); + return result; +}; + +// ============================================================================ +// SCIENTIFIC NOTATION +// ============================================================================ + +/** Expand a float's exponent form ('1.5e-7') into a plain decimal string */ +const expandExponent = (str) => { + const negative = str.startsWith('-'); + const [m, e] = (negative ? str.slice(1) : str).split('e'); + const exp = Number(e); + const mDigits = m.replace('.', ''); + const point = m.split('.')[0].length + exp; + let out; + if (point <= 0) out = `0.${'0'.repeat(-point)}${mDigits}`; + else if (point >= mDigits.length) out = `${mDigits}${'0'.repeat(point - mDigits.length)}`; + else out = `${mDigits.slice(0, point)}.${mDigits.slice(point)}`; + return (negative ? '-' : '') + out; +}; + +/** Normalize number | bigint | numeric string to a plain decimal string, or null */ +const toPlainDecimal = (n) => { + if (typeof n === 'bigint') return n.toString(); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return null; + const str = n.toString(); + return str.includes('e') ? expandExponent(str) : str; + } + if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) return n.trim(); + return null; +}; + +/** + * Scientific notation with an exact decimal mantissa (no float drift). + * @param {number|bigint|string} n - The number + * @param {Object} [opt] - Options object + * @param {number} [opt.digits=12] - Maximum significant digits (rounds half up) + * @param {string} [opt.format='unicode'] - 'unicode' (1.984 × 10³), 'caret' (1.984 × 10^3), + * 'e' (1.984e3), or 'words' (one point nine eight four times ten to the third) + * @param {string} [opt.cap] - Capitalization for the words format + * @returns {string|false} + * + * @example + * scientific(1984) // '1.984 × 10³' + * scientific(0.00042) // '4.2 × 10⁻⁴' + * scientific(1984, { format: 'e' }) // '1.984e3' + * scientific(1984, { format: 'words' }) // 'one point nine eight four times ten to the third' + */ +const scientific = (n, opt) => { + let str = toPlainDecimal(n); + if (str === null) return false; + + const negative = str.startsWith('-'); + if (negative) str = str.slice(1); + const [rawInt, fracPart = ''] = str.split('.'); + const intPart = rawInt.replace(/^0+/, ''); + const all = intPart + fracPart; + const firstNonZero = all.search(/[1-9]/); + + let exponent = 0; + let sig = '0'; + if (firstNonZero !== -1) { + exponent = intPart ? intPart.length - 1 : -(firstNonZero + 1); + sig = all.slice(firstNonZero).replace(/0+$/, '') || '0'; + } + + const maxDigits = Number.isFinite(opt?.digits) ? Math.max(1, Math.min(Math.trunc(opt.digits), 36)) : 12; + if (sig.length > maxDigits) { + const rounded = BigInt(sig.slice(0, maxDigits)) + (Number(sig[maxDigits]) >= 5 ? 1n : 0n); + let roundedStr = rounded.toString(); + if (roundedStr.length > maxDigits) { + // 999 → 1000 carries into the exponent + exponent++; + roundedStr = roundedStr.slice(0, -1); + } + sig = roundedStr.replace(/0+$/, '') || '0'; + } + + const mantissa = sig.length > 1 ? `${sig[0]}.${sig.slice(1)}` : sig; + const sign = negative ? '-' : ''; + const format = opt?.format || 'unicode'; + + if (format === 'e') return `${sign}${mantissa}e${exponent}`; + if (format === 'caret') return `${sign}${mantissa} × 10^${exponent}`; + if (format === 'words') { + const mantissaWords = decimal(`${sign}${mantissa}`); + if (mantissaWords === false) return false; + let result = mantissaWords; + if (exponent !== 0) { + const power = exponent < 0 ? `negative ${ordinal(-exponent)}` : ordinal(exponent); + result += ` times ten to the ${power}`; + } + return opt?.cap ? cap(result, opt.cap) : result; + } + if (format !== 'unicode') return false; + return `${sign}${mantissa} × 10${fancy(exponent, 'superscript')}`; +}; + +// ============================================================================ +// RADIX (binary, octal, hex) +// ============================================================================ + +const RADIX_PREFIXES = Object.freeze({ 2: '0b', 8: '0o', 16: '0x' }); + +/** + * Integer in another base, 2 to 36. + * @param {number|bigint|string} n - Integer + * @param {number} [base=2] - Radix, 2 to 36 + * @param {Object} [opt] - Options object + * @param {boolean} [opt.prefix] - Add 0b / 0o / 0x for bases 2, 8, 16 + * @param {boolean} [opt.upper] - Uppercase letter digits + * @param {number} [opt.pad] - Left-pad with zeros to this many digits + * @returns {string|false} + * + * @example + * radix(42) // '101010' + * radix(42, 16) // '2a' + * radix(255, 16, { prefix: true, upper: true }) // '0xFF' + */ +const radix = (n, base = 2, opt) => { + if (!Number.isInteger(base) || base < 2 || base > 36) return false; + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && Math.abs(n) <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^-?\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + + const negative = value < 0n; + let digits = (negative ? -value : value).toString(base); + if (opt?.upper) digits = digits.toUpperCase(); + if (opt?.pad) digits = digits.padStart(opt.pad, '0'); + const prefix = opt?.prefix ? (RADIX_PREFIXES[base] || '') : ''; + return `${negative ? '-' : ''}${prefix}${digits}`; +}; + +/** Binary: 42 → '101010' */ +const binary = (n, opt) => radix(n, 2, opt); +/** Octal: 42 → '52' */ +const octal = (n, opt) => radix(n, 8, opt); +/** Hexadecimal: 42 → '2a' */ +const hex = (n, opt) => radix(n, 16, opt); + +// ============================================================================ +// BYTES +// ============================================================================ + +const BYTE_UNITS = Object.freeze({ + decimal: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], + binary: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'], + decimalWords: ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte', 'zettabyte', 'yottabyte'], + binaryWords: ['byte', 'kibibyte', 'mebibyte', 'gibibyte', 'tebibyte', 'pebibyte', 'exbibyte', 'zebibyte', 'yobibyte'] +}); + +const BIT_UNITS = Object.freeze({ + decimal: ['b', 'kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'], + binary: ['b', 'Kib', 'Mib', 'Gib', 'Tib', 'Pib', 'Eib', 'Zib', 'Yib'], + decimalWords: ['bit', 'kilobit', 'megabit', 'gigabit', 'terabit', 'petabit', 'exabit', 'zettabit', 'yottabit'], + binaryWords: ['bit', 'kibibit', 'mebibit', 'gibibit', 'tebibit', 'pebibit', 'exbibit', 'zebibit', 'yobibit'] +}); + +/** Shared engine for bytes() and bits(): all rounding in BigInt, so precision holds at any size */ +const dataSize = (n, opt, table) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && Number.isInteger(n) && n <= Number.MAX_SAFE_INTEGER) value = BigInt(n); + else if (typeof n === 'string' && /^\d+$/.test(n.trim())) value = BigInt(n.trim()); + else return false; + if (value < 0n) return false; + + const step = opt?.binary ? 1024n : 1000n; + const units = opt?.binary ? table.binary : table.decimal; + const words = opt?.binary ? table.binaryWords : table.decimalWords; + // Clamp to a finite integer 0-6 so BigInt() cannot throw on 1.5, NaN, or Infinity + const digits = Number.isFinite(opt?.digits) ? Math.max(0, Math.min(Math.trunc(opt.digits), 6)) : 1; + const precision = 10n ** BigInt(digits); + + let unit = 0; + let scale = 1n; + while (unit < units.length - 1 && value >= scale * step) { + scale *= step; + unit++; + } + // amount in units of 10^-digits, rounded half up + let fixed = (value * precision * 2n + scale) / (scale * 2n); + if (fixed >= step * precision && unit < units.length - 1) { + unit++; + scale *= step; + fixed = (value * precision * 2n + scale) / (scale * 2n); } - if (typeof n !== 'number' || isNaN(n)) return false; + const whole = (fixed / precision).toString(); + const frac = digits ? (fixed % precision).toString().padStart(digits, '0').replace(/0+$/, '') : ''; + const amountStr = frac ? `${whole}.${frac}` : whole; - if (n >= 0) return cardinal(n, opt); + if (opt?.long) { + const amountWords = frac ? decimal(amountStr) : cardinal(BigInt(whole)); + if (amountWords === false) return false; + const noun = amountStr === '1' ? words[unit] : `${words[unit]}s`; + return `${amountWords} ${noun}`; + } + return `${amountStr} ${units[unit]}`; +}; - const result = cardinal(Math.abs(n), opt); +/** + * Human-readable byte sizes. + * @param {number|bigint|string} n - Non-negative integer count of bytes + * @param {Object} [opt] - Options object + * @param {boolean} [opt.binary] - Use 1024 steps and KiB/MiB units + * @param {number} [opt.digits=1] - Maximum decimal places + * @param {boolean} [opt.long] - Spell it out: 'one point five kilobytes' + * @returns {string|false} + * + * @example + * bytes(1536) // '1.5 KB' + * bytes(1536, { binary: true }) // '1.5 KiB' + * bytes(1536, { long: true }) // 'one point five kilobytes' + */ +const bytes = (n, opt) => dataSize(n, opt, BYTE_UNITS); + +/** + * Human-readable bit counts (bandwidth style: kb, Mb, Gb). + * @param {number|bigint|string} n - Non-negative integer count of bits + * @param {Object} [opt] - Same options as bytes() + * @returns {string|false} + * + * @example + * bits(1500000) // '1.5 Mb' + * bits(1536, { binary: true }) // '1.5 Kib' + * bits(1500000, { long: true }) // 'one point five megabits' + */ +const bits = (n, opt) => dataSize(n, opt, BIT_UNITS); + +// ============================================================================ +// MORSE CODE +// ============================================================================ + +const MORSE_DIGITS = Object.freeze(['-----', '.----', '..---', '...--', '....-', '.....', '-....', '--...', '---..', '----.']); + +/** + * International Morse code for the digits. Digits are separated by a space, + * a decimal point is '.-.-.-' and a minus sign '-....-'. + * @param {number|bigint|string} n - The number + * @returns {string|false} + * + * @example + * morse(42) // '....- ..---' + * morse(3.1) // '...-- .-.-.- .----' + */ +const morse = (n) => { + let str; + if (typeof n === 'bigint') str = n.toString(); + else if (typeof n === 'number') { + if (!Number.isFinite(n)) return false; + str = Number.isInteger(n) ? BigInt(n).toString() : n.toString(); + if (str.includes('e')) return false; + } else if (typeof n === 'string' && /^-?\d+(\.\d+)?$/.test(n.trim())) str = n.trim(); + else return false; + + return [...str].map((ch) => { + if (ch === '-') return '-....-'; + if (ch === '.') return '.-.-.-'; + return MORSE_DIGITS[Number(ch)]; + }).join(' '); +}; + + +const negative = (n, opt) => { + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && !isNaN(n)) value = n; + else return false; + + if (value >= 0) return cardinal(value, opt); + + // Case the whole phrase once; casing the inner words first would break camel/pascal + const inner = opt ? { ...opt, cap: undefined, punc: undefined } : opt; + const result = cardinal(typeof value === 'bigint' ? -value : Math.abs(value), inner); if (result === false) return false; let s = `negative ${result}`; if (opt?.cap) s = cap(s, opt.cap); + if (opt?.punc !== undefined) s = punc(s, opt.punc); return s; }; @@ -598,8 +1093,15 @@ const fraction = (numerator, denominator, opt) => { }; const year = (y, opt) => { + // Far-future years are read as plain cardinals: "the year ten thousand" + if (typeof y === 'bigint') { + if (y < 0n) return false; + if (y > 9999n) return cardinal(y, opt); + y = Number(y); + } if (typeof y !== 'number' || isNaN(y) || !Number.isInteger(y)) return false; - if (y < 0 || y > 9999) return false; + if (y < 0) return false; + if (y > 9999) return cardinal(y, opt); let result; @@ -637,7 +1139,8 @@ const telephone = (phone, opt) => { const phoneStr = String(phone).replace(/\D/g, ''); if (!phoneStr) return false; - const digitWords = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + // 'oh' for zero is how English speakers usually read phone numbers aloud + const digitWords = [opt?.oh ? 'oh' : 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; const words = phoneStr.split('').map(d => digitWords[parseInt(d, 10)]); let result = words.join(' '); @@ -677,7 +1180,7 @@ const percent = (pct, opt) => { * @returns {string|false} The word representation */ const toWords = (n, opt) => { - const lang = opt?.lang?.toLowerCase() || 'en'; + const lang = typeof opt?.lang === 'string' ? opt.lang.toLowerCase() : 'en'; const langKey = LANGUAGES[lang] || 'english'; let result; @@ -698,7 +1201,7 @@ const toWords = (n, opt) => { result = danish(n); break; case 'chinese': - result = chinese(n); + result = chinese(n, opt); break; case 'hindi': result = hindi(n); @@ -710,7 +1213,7 @@ const toWords = (n, opt) => { result = portuguese(n); break; case 'japanese': - result = japanese(n); + result = japanese(n, opt); break; case 'korean': result = korean(n); @@ -772,5 +1275,18 @@ export { year, telephone, percent, + nth, + compact, + nato, + nato as icao, + nato as military, + morse, + scientific, + radix, + binary, + octal, + hex, + bytes, + bits, toWords }; diff --git a/languages/index.js b/languages/index.js index de73e7b..cf44cd7 100644 --- a/languages/index.js +++ b/languages/index.js @@ -90,6 +90,7 @@ const LANGUAGES = Object.freeze({ id: 'indonesian', indonesian: 'indonesian', 'bahasa indonesia': 'indonesian', + bahasa: 'indonesian', th: 'thai', thai: 'thai', 'ไทย': 'thai', diff --git a/languages/ja.js b/languages/ja.js index 73f9838..3025421 100644 --- a/languages/ja.js +++ b/languages/ja.js @@ -5,6 +5,8 @@ */ const JA_DIGITS = Object.freeze(['', '一', '二', '三', '四', '五', '六', '七', '八', '九']); +/** Daiji (大字) anti-fraud forms used on banknotes and legal documents */ +const JA_DAIJI = Object.freeze(['', '壱', '弐', '参', '四', '五', '六', '七', '八', '九']); const JA_SCALES = Object.freeze(['', '万', '億', '兆', '京', '垓', '𥝱', '穣', '溝']); /** Maximum supported value (10^36 - 1, up to 溝) */ @@ -16,8 +18,10 @@ const MAX_VALUE = 10n ** 36n - 1n; * @param {number} grp - The group value (0-9999) * @returns {string} The Japanese representation */ -const groupToJa = (grp, afterScale = false) => { +const groupToJa = (grp, afterScale = false, formal = false) => { if (grp === 0) return ''; + const digits = formal ? JA_DAIJI : JA_DIGITS; + const ten = formal ? '拾' : '十'; const thousands = Math.floor(grp / 1000); const hundreds = Math.floor((grp % 1000) / 100); @@ -28,35 +32,36 @@ const groupToJa = (grp, afterScale = false) => { // Thousands: 1 before 千 is omitted at the start (千) but kept after a // higher scale word (二万一千) + // Daiji always writes the 壱 (壱千, 壱百, 壱拾) if (thousands > 0) { - if (thousands === 1 && !afterScale) { + if (thousands === 1 && !afterScale && !formal) { result += '千'; } else { - result += JA_DIGITS[thousands] + '千'; + result += digits[thousands] + '千'; } } // Hundreds: 1 before 百 is omitted if (hundreds > 0) { - if (hundreds === 1) { + if (hundreds === 1 && !formal) { result += '百'; } else { - result += JA_DIGITS[hundreds] + '百'; + result += digits[hundreds] + '百'; } } // Tens: 1 before 十 is omitted if (tens > 0) { - if (tens === 1) { - result += '十'; + if (tens === 1 && !formal) { + result += ten; } else { - result += JA_DIGITS[tens] + '十'; + result += digits[tens] + ten; } } // Ones if (ones > 0) { - result += JA_DIGITS[ones]; + result += digits[ones]; } return result; @@ -65,6 +70,8 @@ const groupToJa = (grp, afterScale = false) => { /** * Convert a number to Japanese words * @param {number|bigint} n - The number to convert + * @param {Object} [opt] - Options object + * @param {boolean} [opt.formal] - Use daiji 大字 numerals (壱弐参, 拾) * @returns {string|false} The Japanese word representation * * @example @@ -72,7 +79,8 @@ const groupToJa = (grp, afterScale = false) => { * japanese(1000) // '千' * japanese(10000) // '一万' */ -const japanese = (n) => { +const japanese = (n, opt) => { + const formal = opt?.formal === true; let num; if (typeof n === 'bigint') { @@ -86,7 +94,7 @@ const japanese = (n) => { return false; } - if (num === 0n) return 'ゼロ'; + if (num === 0n) return formal ? '零' : 'ゼロ'; const str = num.toString(); const len = str.length; @@ -106,7 +114,7 @@ const japanese = (n) => { if (grp === 0) continue; - const grpStr = groupToJa(grp, i > 0); + const grpStr = groupToJa(grp, i > 0, formal); // 1 before 万 and above IS included (handled naturally by groupToJa // since grp=1 produces '一' for the ones digit in the group) diff --git a/languages/zh.js b/languages/zh.js index 95c89e7..6c3a28c 100644 --- a/languages/zh.js +++ b/languages/zh.js @@ -6,6 +6,9 @@ const ZH_ONES = Object.freeze(['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']); const ZH_UNITS = Object.freeze(['', '十', '百', '千']); +/** Financial (大写) anti-fraud forms used on cheques and contracts */ +const ZH_FORMAL_ONES = Object.freeze(['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']); +const ZH_FORMAL_UNITS = Object.freeze(['', '拾', '佰', '仟']); const ZH_ILLIONS = Object.freeze(['', '万', '亿', '兆', '京', '垓', '秭', '穰', '沟', '涧', '正', '载']); const MAX_VALUE = 10n ** 36n - 1n; @@ -13,14 +16,20 @@ const MAX_VALUE = 10n ** 36n - 1n; /** * Convert a number to Mandarin Chinese words * @param {number|bigint} n - The number to convert + * @param {Object} [opt] - Options object + * @param {boolean} [opt.formal] - Use financial 大写 numerals (壹贰叁, 拾佰仟) * @returns {string|false} The Mandarin word representation * * @example * chinese(42) // '四十二' * chinese(1000) // '一千' * chinese(10000) // '一万' + * chinese(42, { formal: true }) // '肆拾贰' */ -const chinese = (n) => { +const chinese = (n, opt) => { + const formal = opt?.formal === true; + const digitWords = formal ? ZH_FORMAL_ONES : ZH_ONES; + const unitWords = formal ? ZH_FORMAL_UNITS : ZH_UNITS; let num; if (typeof n === 'bigint') { @@ -71,27 +80,27 @@ const chinese = (n) => { let innerZero = false; if (thousands > 0) { - grpStr += ZH_ONES[thousands] + ZH_UNITS[3]; + grpStr += digitWords[thousands] + unitWords[3]; innerZero = false; } else if (result || i > 0) { innerZero = true; } if (hundreds > 0) { - if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[hundreds] + ZH_UNITS[2]; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; + grpStr += digitWords[hundreds] + unitWords[2]; innerZero = false; } else if (thousands > 0) { innerZero = true; } if (tens > 0) { - if (innerZero && grpStr) grpStr += '零'; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; // Special: 10-19 at start is just 十X, not 一十X - if (tens === 1 && !result && thousands === 0 && hundreds === 0) { - grpStr += ZH_UNITS[1]; + if (tens === 1 && !formal && !result && thousands === 0 && hundreds === 0) { + grpStr += unitWords[1]; } else { - grpStr += ZH_ONES[tens] + ZH_UNITS[1]; + grpStr += digitWords[tens] + unitWords[1]; } innerZero = false; } else if (hundreds > 0 || thousands > 0) { @@ -99,8 +108,8 @@ const chinese = (n) => { } if (ones > 0) { - if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[ones]; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; + grpStr += digitWords[ones]; } result += grpStr; diff --git a/numerals.js b/numerals.js new file mode 100644 index 0000000..a09a226 --- /dev/null +++ b/numerals.js @@ -0,0 +1,239 @@ +/** + * Alternative numeral systems and Unicode digit styles. + * Everything here is pure, table-driven, and renders with Unicode glyphs. + * Only blocks with broad system-font coverage are included (Mayan numerals + * and tally marks were dropped for lack of fonts on macOS). + * @module numerals + */ + +// ============================================================================ +// SHARED +// ============================================================================ + +/** Normalize a non-negative integer input to BigInt, or null if invalid */ +const toCount = (n) => { + if (typeof n === 'bigint') return n >= 0n ? n : null; + if (typeof n === 'number') return Number.isInteger(n) && n >= 0 ? BigInt(n) : null; + if (typeof n === 'string' && /^\d+$/.test(n.trim())) return BigInt(n.trim()); + return null; +}; + +/** Normalize any numeric input (sign, decimal point) to a digit string, or null */ +const toDigitString = (n) => { + if (typeof n === 'bigint') return n.toString(); + if (typeof n === 'number') { + if (!Number.isFinite(n)) return null; + const str = n.toString(); + if (str.includes('e')) return Number.isInteger(n) ? BigInt(n).toString() : null; + return str; + } + if (typeof n === 'string') { + const str = n.trim(); + return /^-?\d+(\.\d+)?$/.test(str) ? str : null; + } + return null; +}; + +// ============================================================================ +// UNICODE DIGIT STYLES +// ============================================================================ + +/** Digit tables: ten glyphs for 0-9, plus minus and point */ +const FANCY_STYLES = Object.freeze({ + circled: { digits: '⓪①②③④⑤⑥⑦⑧⑨', minus: '−', point: '·' }, + superscript: { digits: '⁰¹²³⁴⁵⁶⁷⁸⁹', minus: '⁻', point: '˙' }, + subscript: { digits: '₀₁₂₃₄₅₆₇₈₉', minus: '₋', point: '.' }, + fullwidth: { digits: '0123456789', minus: '-', point: '.' }, + bold: { digits: '𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', minus: '−', point: '.' }, + doublestruck: { digits: '𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡', minus: '−', point: '.' }, + sans: { digits: '𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫', minus: '−', point: '.' }, + monospace: { digits: '𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿', minus: '−', point: '.' }, + keycap: { digits: ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'], minus: '➖', point: '.' }, + emoji: { digits: ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'], minus: '➖', point: '.' }, + // Clock faces: 1-9 o'clock, with 12 o'clock standing in for 0 + clock: { digits: '🕛🕐🕑🕒🕓🕔🕕🕖🕗🕘', minus: '−', point: '·' }, + // Braille: numeric indicator ⠼ then a-j, decimal point ⠨, minus ⠤ + braille: { digits: '⠚⠁⠃⠉⠙⠑⠋⠛⠓⠊', minus: '⠤', point: '⠨', prefix: '⠼' } +}); + +/** Style names accepted by fancy() */ +const FANCY_STYLE_NAMES = Object.freeze(Object.keys(FANCY_STYLES)); + +/** + * Render a number's digits in a Unicode style. + * @param {number|bigint|string} n - The number + * @param {string} [style='circled'] - One of circled, superscript, subscript, + * fullwidth, bold, doublestruck, sans, monospace, keycap (alias emoji), clock, braille + * @returns {string|false} Styled digits or false if invalid + * + * @example + * fancy(42) // '④②' + * fancy(42, 'superscript') // '⁴²' + * fancy(-3.5, 'fullwidth') // '-3.5' + */ +const fancy = (n, style = 'circled') => { + const table = FANCY_STYLES[style]; + if (!table) return false; + const str = toDigitString(n); + if (str === null) return false; + + const glyphs = Array.isArray(table.digits) ? table.digits : [...table.digits]; + let out = table.prefix || ''; + for (const ch of str) { + if (ch === '-') out += table.minus; + else if (ch === '.') out += table.point; + else out += glyphs[Number(ch)]; + } + return out; +}; + +// ============================================================================ +// EGYPTIAN HIEROGLYPHS +// ============================================================================ + +/** Hieroglyphs for 1, 10, 100, ... 1,000,000 (stroke, heel bone, coil, lotus, finger, tadpole, Heh) */ +const EGYPTIAN_SYMBOLS = Object.freeze(['𓏺', '𓎆', '𓍢', '𓆼', '𓂭', '𓆐', '𓁨']); + +/** Largest value expressible with repeated hieroglyphs (9,999,999) */ +const EGYPTIAN_MAX = 9999999n; + +/** + * Egyptian hieroglyphic numerals, additive: each power of ten is a symbol + * repeated up to nine times, largest first. + * @param {number|bigint|string} n - Integer from 1 to 9,999,999 + * @returns {string|false} + * + * @example + * egyptian(42) // '𓎆𓎆𓎆𓎆𓏺𓏺' + * egyptian(1000) // '𓆼' + */ +const egyptian = (n) => { + const count = toCount(n); + if (count === null || count < 1n || count > EGYPTIAN_MAX) return false; + + const digits = count.toString(); + let out = ''; + for (let i = 0; i < digits.length; i++) { + const power = digits.length - 1 - i; + out += EGYPTIAN_SYMBOLS[power].repeat(Number(digits[i])); + } + return out; +}; + +// ============================================================================ +// BABYLONIAN CUNEIFORM +// ============================================================================ + +/** DIŠ (U+12079), the positional unit wedge; GESH2 (U+12415) looks alike but means sixty */ +const CUNEIFORM_ONE = '𒁹'; +const CUNEIFORM_TEN = '𒌋'; +/** Late Babylonian placeholder for an empty sexagesimal position */ +const CUNEIFORM_ZERO = '𒑊'; + +/** + * Babylonian sexagesimal (base 60) numerals. Each position is written with + * tens wedges then unit wedges; positions are separated by a space and an + * empty inner position uses the Late Babylonian placeholder sign. + * @param {number|bigint|string} n - Non-negative integer + * @returns {string|false} + * + * @example + * babylonian(42) // '𒌋𒌋𒌋𒌋𒁹𒁹' + * babylonian(3600) // '𒁹 𒑊 𒑊' + */ +const babylonian = (n) => { + const count = toCount(n); + if (count === null) return false; + if (count === 0n) return CUNEIFORM_ZERO; + + const places = []; + let rest = count; + while (rest > 0n) { + places.unshift(Number(rest % 60n)); + rest /= 60n; + } + return places + .map((p) => (p === 0 ? CUNEIFORM_ZERO : CUNEIFORM_TEN.repeat(Math.floor(p / 10)) + CUNEIFORM_ONE.repeat(p % 10))) + .join(' '); +}; + +// ============================================================================ +// CLOCK FACES +// ============================================================================ + +/** 🕐..🕛 for 1..12 o'clock (U+1F550..), 🕜..🕧 for the half hours (U+1F55C..) */ +const CLOCK_HOURS = Object.freeze([...'🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛']); +const CLOCK_HALVES = Object.freeze([...'🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧']); + +/** + * Clock-face emoji for an hour (0-24, 24-hour values wrap) or an "H:MM" time. + * Minutes round to the nearest half hour; 0 and 24 are 🕛. + * @param {number|string} time - Hour, or 'H:MM' + * @returns {string|false} + * + * @example + * clock(3) // '🕒' + * clock('3:30') // '🕞' + * clock(15) // '🕒' + * clock('23:50') // '🕛' + */ +const clock = (time) => { + let hour; + let minute = 0; + if (typeof time === 'number') { + if (!Number.isInteger(time) || time < 0 || time > 24) return false; + hour = time; + } else if (typeof time === 'string') { + const m = time.trim().match(/^(\d{1,2})(?::(\d{2}))?$/); + if (!m) return false; + hour = Number(m[1]); + minute = Number(m[2] || 0); + if (hour > 24 || minute > 59) return false; + } else { + return false; + } + + // Round to the nearest half hour; :45 and later roll to the next hour + if (minute >= 45) { + hour += 1; + minute = 0; + } + const half = minute >= 15; + const index = (hour + 11) % 12; + return half ? CLOCK_HALVES[index] : CLOCK_HOURS[index]; +}; + +// ============================================================================ +// GREEK (IONIC / MILESIAN) +// ============================================================================ + +const GREEK_ONES = Object.freeze(['', 'α', 'β', 'γ', 'δ', 'ε', 'ϛ', 'ζ', 'η', 'θ']); +const GREEK_TENS = Object.freeze(['', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ϟ']); +const GREEK_HUNDREDS = Object.freeze(['', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', 'ϡ']); +/** Keraia marks a number; the lower keraia marks thousands */ +const GREEK_KERAIA = 'ʹ'; +const GREEK_THOUSANDS = '͵'; + +/** + * Greek alphabetic (Ionic) numerals for 1-9999, with stigma, koppa and sampi + * for 6, 90 and 900 and the keraia marks. + * @param {number|bigint|string} n - Integer from 1 to 9999 + * @returns {string|false} + * + * @example + * greek(42) // 'μβʹ' + * greek(1999) // '͵αϡϟθʹ' + */ +const greek = (n) => { + const count = toCount(n); + if (count === null || count < 1n || count > 9999n) return false; + + const v = Number(count); + const thousands = Math.floor(v / 1000); + const rest = v % 1000; + let out = thousands ? GREEK_THOUSANDS + GREEK_ONES[thousands] : ''; + out += GREEK_HUNDREDS[Math.floor(rest / 100)] + GREEK_TENS[Math.floor((rest % 100) / 10)] + GREEK_ONES[rest % 10]; + return out + GREEK_KERAIA; +}; + +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; diff --git a/package-lock.json b/package-lock.json index 5b4b0c4..980a1bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "numberstring", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "numberstring", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@vitest/coverage-v8": "^4.0.18", diff --git a/package.json b/package.json index b6a61ef..a0bf815 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "numberstring", - "version": "1.1.0", - "description": "Number One Way to Makes Words from Numbers", + "version": "1.2.0", + "description": "Numbers to words in 22 languages, plus ordinals, Roman, NATO, Morse, scientific, hex, hieroglyphs and more. Zero dependencies.", "type": "module", "main": "index.js", "exports": { @@ -18,24 +18,45 @@ "lint": "eslint . --format stylish", "lint:report": "eslint . --format html -o coverage/lint-report.html", "site:build": "node scripts/build-site.js", - "site": "node scripts/build-site.js && node scripts/serve-site.js" + "site": "node scripts/build-site.js && node scripts/serve-site.js", + "site:og": "sh scripts/build-og.sh" }, "repository": { "type": "git", "url": "git+https://github.com/brianfunk/numberstring.git" }, "keywords": [ - "number", - "string", - "word", - "words", - "integer", "bigint", - "text", + "binary", + "braille", + "bytes", + "camelcase", + "compact", "convert", + "cuneiform", + "decillion", + "emoji", "english", + "hex", + "hieroglyphs", + "i18n", + "icao", + "integer", + "mayan", + "morse", + "multilingual", + "nato", + "number", + "number-to-words", + "ordinal", "quintillion", - "decillion" + "roman", + "scientific-notation", + "snake-case", + "string", + "text", + "word", + "words" ], "author": "Brian Funk", "license": "MIT", diff --git a/scripts/build-og.sh b/scripts/build-og.sh new file mode 100755 index 0000000..d143020 --- /dev/null +++ b/scripts/build-og.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# Rasterize site/og.svg to site/og.png (1200x630) with headless Chrome. +# Run after editing og.svg: npm run site:og +set -e +cd "$(dirname "$0")/.." +CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +[ -x "$CHROME" ] || CHROME="$(command -v google-chrome || command -v chromium || command -v chrome)" +"$CHROME" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ + --window-size=1200,630 --screenshot="$PWD/site/og.png" "file://$PWD/site/og.svg" 2>/dev/null +echo "site/og.png rendered from site/og.svg" diff --git a/scripts/build-site.js b/scripts/build-site.js index 51ab049..b23922b 100644 --- a/scripts/build-site.js +++ b/scripts/build-site.js @@ -16,6 +16,7 @@ const lib = join(root, 'site', 'lib'); rmSync(lib, { recursive: true, force: true }); mkdirSync(lib, { recursive: true }); cpSync(join(root, 'index.js'), join(lib, 'index.js')); +cpSync(join(root, 'numerals.js'), join(lib, 'numerals.js')); cpSync(join(root, 'languages'), join(lib, 'languages'), { recursive: true }); -process.stdout.write('site/lib/ staged from index.js + languages/\n'); +process.stdout.write('site/lib/ staged from index.js + numerals.js + languages/\n'); diff --git a/scripts/serve-site.js b/scripts/serve-site.js index 56eeb1f..f9e980a 100644 --- a/scripts/serve-site.js +++ b/scripts/serve-site.js @@ -19,6 +19,7 @@ const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', + '.png': 'image/png', '.ico': 'image/x-icon' }; diff --git a/site/app.js b/site/app.js index 0c9dd88..f2f69ac 100644 --- a/site/app.js +++ b/site/app.js @@ -1,5 +1,7 @@ import numberstring, { - comma, ordinal, roman, year, currency, telephone, fraction + comma, ordinal, roman, year, currency, telephone, fraction, + nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse, + scientific, binary, octal, hex, bytes, bits } from './lib/index.js'; const LANGS = [ @@ -32,10 +34,11 @@ const interpret = (raw) => { return { str, value, negative, isDecimal, magnitude: digits.length }; }; -const row = (label, text) => { +const row = (label, text, cls) => { const dt = document.createElement('dt'); dt.textContent = label; const dd = document.createElement('dd'); + if (cls) dd.classList.add(cls); if (text === false || text == null) { dd.textContent = '—'; dd.className = 'na'; @@ -45,6 +48,24 @@ const row = (label, text) => { facts.append(dt, dd); }; +/** Extra row for the Chinese 大写 / Japanese 大字 financial numerals */ +const formalRow = (code, value) => { + const tr = document.createElement('tr'); + const c = document.createElement('td'); + c.className = 'code'; + c.textContent = code; + const n = document.createElement('td'); + n.className = 'name'; + n.textContent = code === 'zh' ? '大写' : '大字'; + const w = document.createElement('td'); + w.className = 'words'; + const fn = code === 'zh' ? chinese : japanese; + const out = value === null ? false : fn(value, { formal: true }); + w.textContent = out === false ? '—' : out; + tr.append(c, n, w); + return tr; +}; + const render = (raw) => { const parsed = interpret(raw); facts.replaceChildren(); @@ -73,13 +94,41 @@ const render = (raw) => { row('comma', isDecimal ? false : comma(value)); row('ordinal', wholeInt && value !== 0 && value !== 0n ? ordinal(value) : false); - row('roman', smallInt && value >= 1 && value <= 3999 ? roman(value) : false); - row('year', smallInt && value >= 1000 && value <= 9999 ? year(value) : false); + row('roman', smallInt && value >= 1 && value <= 3999999999 ? roman(value) : false, 'roman'); + row('year', wholeInt && value >= 1 ? year(value) : false); row('currency', !negative && typeof value === 'number' && value < 1e15 ? currency(`$${parsed.str}`) : false); - row('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str) : false); - row('fraction', smallInt && value >= 2 && value <= 1000 ? `1/${value} = ${fraction(1, value)}` : false); + row('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str, { oh: true }) : false); + row('pilot', nato(parsed.str)); + row('scientific', scientific(parsed.str)); + row('binary', !isDecimal ? binary(value, { prefix: true }) : false, 'roman'); + row('octal', !isDecimal ? octal(value, { prefix: true }) : false, 'roman'); + row('hex', !isDecimal ? hex(value, { prefix: true }) : false, 'roman'); + row('bytes', wholeInt ? bytes(value) : false); + row('bits', wholeInt ? bits(value) : false); + row('morse', morse(parsed.str), 'roman'); + row('fraction', smallInt && value >= 2 ? fraction(1, value) : false); + row('british', wholeInt ? numberstring(value, { and: true }) : false); + row('nth', wholeInt ? nth(value) : false); + row('compact', compact(parsed.str)); row('title', numberstring(parsed.str, { cap: 'title' })); + row('sentence', numberstring(parsed.str, { cap: 'sentence', punc: '.' })); row('shout', numberstring(parsed.str, { cap: 'upper', punc: '!' })); + row('camelCase', numberstring(parsed.str, { cap: 'camel' }), 'roman'); + row('PascalCase', numberstring(parsed.str, { cap: 'pascal' }), 'roman'); + row('snake_case', numberstring(parsed.str, { cap: 'snake' }), 'roman'); + row('kebab-case', numberstring(parsed.str, { cap: 'kebab' }), 'roman'); + row('CONSTANT', numberstring(parsed.str, { cap: 'constant' }), 'roman'); + row('dot.case', numberstring(parsed.str, { cap: 'dot' }), 'roman'); + row('circled', fancy(parsed.str, 'circled')); + row('superscript', fancy(parsed.str, 'superscript')); + row('fullwidth', fancy(parsed.str, 'fullwidth')); + row('doublestruck', fancy(parsed.str, 'doublestruck')); + row('emoji', fancy(parsed.str, 'emoji')); + row('clocks', fancy(parsed.str, 'clock'), 'glyphs'); + row('braille', fancy(parsed.str, 'braille')); + row('egyptian', wholeInt ? egyptian(value) : false, 'glyphs'); + row('babylonian', wholeInt ? babylonian(value) : false, 'glyphs'); + row('greek', wholeInt ? greek(value) : false, 'glyphs'); for (const [code, name] of LANGS) { const tr = document.createElement('tr'); @@ -96,6 +145,7 @@ const render = (raw) => { w.textContent = out === false ? '—' : out; tr.append(c, n, w); langs.append(tr); + if (code === 'zh' || code === 'ja') langs.append(formalRow(code, wholeInt ? value : null)); } }; @@ -108,7 +158,12 @@ document.querySelectorAll('.chips button').forEach((b) => { }); }); -const fromHash = decodeURIComponent(location.hash.slice(1)); +let fromHash = ''; +try { + fromHash = decodeURIComponent(location.hash.slice(1)); +} catch { + fromHash = ''; +} if (fromHash) input.value = fromHash; render(input.value); input.addEventListener('change', () => { diff --git a/site/index.html b/site/index.html index 0f95766..c51d1c0 100644 --- a/site/index.html +++ b/site/index.html @@ -4,40 +4,42 @@ numberstring - + - - - + + + - +
+ + /## /"" /"" + | ## | "" |__/ + /####### /## /## /######/#### | ####### /###### /###### /""""""" /"""""" /"""""" /"" /""""""" /"""""" +| ##__ ##| ## | ##| ##_ ##_ ##| ##__ ## /##__ ## /##__ ## /""_____/|_ ""_/ /""__ ""| ""| ""__ "" /""__ "" +| ## \ ##| ## | ##| ## \ ## \ ##| ## \ ##| ########| ## \__/| """""" | "" | "" \__/| ""| "" \ ""| "" \ "" +| ## | ##| ## | ##| ## | ## | ##| ## | ##| ##_____/| ## \____ "" | "" /""| "" | ""| "" | ""| "" | "" +| ## | ##| ######/| ## | ## | ##| #######/| #######| ## /"""""""/ | """"/| "" | ""| "" | ""| """"""" +|__/ |__/ \______/ |__/ |__/ |__/|_______/ \_______/|__/ |_______/ \___/ |__/ |__/|__/ |__/ \____ "" + /"" \ "" + | """"""/ + \______/ +

numberstring

Number One Way to Makes Words from Numbers

diff --git a/site/og.png b/site/og.png new file mode 100644 index 0000000..7d0e309 Binary files /dev/null and b/site/og.png differ diff --git a/site/og.svg b/site/og.svg index 511409a..44825a2 100644 --- a/site/og.svg +++ b/site/og.svg @@ -6,5 +6,5 @@ forty-two cuarenta y dos · quarante-deux · zweiundvierzig 四十二 · сорок два · बयालीस · اثنان وأربعون - 22 languages · ordinals · Roman numerals · decimals · currency · BigInt to 10³⁶ · zero dependencies + 22 languages · ordinals · NATO · Morse · scientific · hex · Roman · hieroglyphs · emoji · zero dependencies diff --git a/site/style.css b/site/style.css index 3346dc7..ed13e4a 100644 --- a/site/style.css +++ b/site/style.css @@ -50,6 +50,10 @@ header { text-align: center; margin-bottom: 24px; } margin: 0 auto 8px; overflow: hidden; white-space: pre; + /* header is centered; a pre would center each line separately and skew the art */ + text-align: left; + width: max-content; + max-width: 100%; } h1 { @@ -117,6 +121,12 @@ h2 { .facts dt { color: var(--muted); font-family: var(--mono); font-size: 0.9rem; } .facts dd { margin: 0; overflow-wrap: anywhere; } .facts dd.na { color: var(--muted); } +/* vinculum bars need room so adjacent overlines don't merge */ +.facts dd.roman { font-family: var(--mono); letter-spacing: 0.12em; } +/* historical glyph blocks render small in most fonts */ +.facts dd.glyphs { font-size: 1.35em; line-height: 1.2; letter-spacing: 0.05em; } +header a.home { color: inherit; text-decoration: none; display: inline-block; } +header a.home:hover .ascii { color: var(--accent-2); } table { width: 100%; border-collapse: collapse; } td { padding: 8px 8px; border-top: 1px solid var(--border); vertical-align: top; } diff --git a/test/extras.test.js b/test/extras.test.js new file mode 100644 index 0000000..071d1d3 --- /dev/null +++ b/test/extras.test.js @@ -0,0 +1,585 @@ +import { describe, it, expect } from 'vitest'; +import numberstring, { + ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, nato, icao, military, morse, telephone, + scientific, radix, binary, octal, hex, bytes, bits, clock, CAP_STYLES, roman, decimal, currency, year, + egyptian, babylonian, greek, + chinese, japanese, toWords +} from '../index.js'; + +describe('and option (British style)', () => { + it('inserts and after hundreds', () => { + expect(numberstring(123, { and: true })).toBe('one hundred and twenty-three'); + expect(numberstring(101, { and: true })).toBe('one hundred and one'); + expect(numberstring(100, { and: true })).toBe('one hundred'); + }); + + it('inserts and before a final group under one hundred', () => { + expect(numberstring(1001, { and: true })).toBe('one thousand and one'); + expect(numberstring(2000001, { and: true })).toBe('two million and one'); + expect(numberstring(1101, { and: true })).toBe('one thousand one hundred and one'); + }); + + it('does not add and where nothing follows', () => { + expect(numberstring(1000000, { and: true })).toBe('one million'); + expect(numberstring(1000, { and: true })).toBe('one thousand'); + expect(numberstring(42, { and: true })).toBe('forty-two'); + }); + + it('is off by default', () => { + expect(numberstring(123)).toBe('one hundred twenty-three'); + expect(numberstring(1001)).toBe('one thousand one'); + }); + + it('applies to the integer part of decimals', () => { + expect(numberstring('123.4', { and: true })).toBe('one hundred and twenty-three point four'); + expect(numberstring(1001.5, { and: true })).toBe('one thousand and one point five'); + }); + + it('flows through negatives and ordinals', () => { + expect(numberstring(-1001, { and: true })).toBe('negative one thousand and one'); + expect(ordinal(101, { and: true })).toBe('one hundred and first'); + expect(numberstring(1001, { and: true, cap: 'title' })).toBe('One Thousand And One'); + }); +}); + +describe('nth', () => { + it('uses st, nd, rd, th', () => { + expect(nth(1)).toBe('1st'); + expect(nth(2)).toBe('2nd'); + expect(nth(3)).toBe('3rd'); + expect(nth(4)).toBe('4th'); + expect(nth(0)).toBe('0th'); + }); + + it('handles the teens', () => { + expect(nth(11)).toBe('11th'); + expect(nth(12)).toBe('12th'); + expect(nth(13)).toBe('13th'); + expect(nth(111)).toBe('111th'); + expect(nth(112)).toBe('112th'); + expect(nth(113)).toBe('113th'); + }); + + it('handles larger numbers', () => { + expect(nth(21)).toBe('21st'); + expect(nth(22)).toBe('22nd'); + expect(nth(23)).toBe('23rd'); + expect(nth(101)).toBe('101st'); + expect(nth(1000)).toBe('1000th'); + }); + + it('accepts strings, BigInt, and negatives', () => { + expect(nth('42')).toBe('42nd'); + expect(nth(10n ** 20n + 1n)).toBe('100000000000000000001st'); + expect(nth(-1)).toBe('-1st'); + }); + + it('rejects invalid input', () => { + expect(nth(1.5)).toBe(false); + expect(nth('abc')).toBe(false); + expect(nth(NaN)).toBe(false); + expect(nth(null)).toBe(false); + }); +}); + +describe('compact', () => { + it('leaves small numbers alone', () => { + expect(compact(999)).toBe('999'); + expect(compact(12)).toBe('12'); + expect(compact(0.5)).toBe('0.5'); + expect(compact(-7)).toBe('-7'); + }); + + it('abbreviates thousands and up', () => { + expect(compact(1000)).toBe('1K'); + expect(compact(1500)).toBe('1.5K'); + expect(compact(1234567)).toBe('1.2M'); + expect(compact(2300000000)).toBe('2.3B'); + expect(compact(1e12)).toBe('1T'); + expect(compact(10n ** 21n)).toBe('1Sx'); + }); + + it('carries when rounding crosses a scale', () => { + expect(compact(999950)).toBe('1M'); + expect(compact(999999)).toBe('1M'); + expect(compact(999.99)).toBe('1K'); + expect(compact(999.999, { digits: 2 })).toBe('1K'); + expect(compact(-999.99)).toBe('-1K'); + expect(compact(999.99, { long: true })).toBe('1 thousand'); + expect(compact(999.4)).toBe('999.4'); + }); + + it('handles negatives, strings, and decimals', () => { + expect(compact(-1500)).toBe('-1.5K'); + expect(compact('1500.75')).toBe('1.5K'); + }); + + it('honors digits and long options', () => { + expect(compact(1234567, { digits: 2 })).toBe('1.23M'); + expect(compact(1234567, { digits: 0 })).toBe('1M'); + expect(compact(1500000, { long: true })).toBe('1.5 million'); + expect(compact(2000, { long: true })).toBe('2 thousand'); + }); + + it('ignores leading zeros when picking a scale', () => { + expect(compact('0001')).toBe('1'); + expect(compact('0001000')).toBe('1K'); + expect(compact('00')).toBe('0'); + }); + + it('keeps the top scale instead of failing at the upper bound', () => { + expect(compact(10n ** 36n - 1n)).toBe('1000Dc'); + expect(compact(10n ** 36n - 1n, { long: true })).toBe('1000 decillion'); + }); + + it('rejects invalid input', () => { + expect(compact('abc')).toBe(false); + expect(compact(NaN)).toBe(false); + expect(compact(Infinity)).toBe(false); + expect(compact(1e37)).toBe(false); + expect(compact({})).toBe(false); + }); +}); + +describe('fancy', () => { + it('defaults to circled digits', () => { + expect(fancy(42)).toBe('④②'); + expect(fancy(0)).toBe('⓪'); + }); + + it('supports every listed style', () => { + const expected = { + circled: '④②', + superscript: '⁴²', + subscript: '₄₂', + fullwidth: '42', + bold: '𝟒𝟐', + doublestruck: '𝟜𝟚', + sans: '𝟦𝟤', + monospace: '𝟺𝟸', + keycap: '4️⃣2️⃣', + emoji: '4️⃣2️⃣', + clock: '🕓🕑', + braille: '⠼⠙⠃' + }; + expect([...FANCY_STYLE_NAMES].sort()).toEqual(Object.keys(expected).sort()); + for (const [style, out] of Object.entries(expected)) { + expect(fancy(42, style)).toBe(out); + } + }); + + it('maps minus signs and decimal points', () => { + expect(fancy(-3.5, 'fullwidth')).toBe('-3.5'); + expect(fancy(-3.5, 'superscript')).toBe('⁻³˙⁵'); + expect(fancy(-3.5, 'braille')).toBe('⠼⠤⠉⠨⠑'); + expect(fancy('-42', 'circled')).toBe('−④②'); + expect(fancy(814, 'clock')).toBe('🕗🕐🕓'); + expect(fancy(10.5, 'clock')).toBe('🕐🕛·🕔'); + }); + + it('handles BigInt and exponent-form integers', () => { + expect(fancy(10n ** 3n, 'doublestruck')).toBe('𝟙𝟘𝟘𝟘'); + expect(fancy(1e21, 'subscript')).toBe('₁₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀₀'); + }); + + it('rejects unknown styles and bad input', () => { + expect(fancy(42, 'wingdings')).toBe(false); + expect(fancy('abc')).toBe(false); + expect(fancy(NaN)).toBe(false); + expect(fancy(1.5e-7)).toBe(false); + }); +}); + +describe('egyptian', () => { + it('repeats symbols additively', () => { + expect(egyptian(1)).toBe('𓏺'); + expect(egyptian(9)).toBe('𓏺'.repeat(9)); + expect(egyptian(10)).toBe('𓎆'); + expect(egyptian(42)).toBe('𓎆𓎆𓎆𓎆𓏺𓏺'); + expect(egyptian(1000)).toBe('𓆼'); + expect(egyptian(1000000)).toBe('𓁨'); + expect(egyptian(1234567)).toBe('𓁨𓆐𓆐𓂭𓂭𓂭𓆼𓆼𓆼𓆼𓍢𓍢𓍢𓍢𓍢𓎆𓎆𓎆𓎆𓎆𓎆𓏺𓏺𓏺𓏺𓏺𓏺𓏺'); + }); + + it('rejects zero and values beyond 9,999,999', () => { + expect(egyptian(0)).toBe(false); + expect(egyptian(10000000)).toBe(false); + expect(egyptian(-1)).toBe(false); + expect(egyptian(1.5)).toBe(false); + }); +}); + +describe('babylonian', () => { + it('writes base-60 places with tens and ones wedges', () => { + expect(babylonian(1)).toBe('𒁹'); + expect(babylonian(10)).toBe('𒌋'); + expect(babylonian(42)).toBe('𒌋𒌋𒌋𒌋𒁹𒁹'); + expect(babylonian(59)).toBe('𒌋𒌋𒌋𒌋𒌋𒁹𒁹𒁹𒁹𒁹𒁹𒁹𒁹𒁹'); + expect(babylonian(60)).toBe('𒁹 𒑊'); + expect(babylonian(61)).toBe('𒁹 𒁹'); + expect(babylonian(3600)).toBe('𒁹 𒑊 𒑊'); + expect(babylonian(1984)).toBe('𒌋𒌋𒌋𒁹𒁹𒁹 𒁹𒁹𒁹𒁹'); + }); + + it('uses the placeholder for zero', () => { + expect(babylonian(0)).toBe('𒑊'); + }); + + it('accepts BigInt and rejects invalid input', () => { + expect(babylonian(10n ** 18n)).toMatch(/^𒁹/); + expect(babylonian(-1)).toBe(false); + expect(babylonian('x')).toBe(false); + }); +}); + +describe('greek', () => { + it('uses Ionic letters with keraia', () => { + expect(greek(1)).toBe('αʹ'); + expect(greek(6)).toBe('ϛʹ'); + expect(greek(42)).toBe('μβʹ'); + expect(greek(90)).toBe('ϟʹ'); + expect(greek(900)).toBe('ϡʹ'); + expect(greek(1999)).toBe('͵αϡϟθʹ'); + expect(greek(2026)).toBe('͵βκϛʹ'); + expect(greek(1000)).toBe('͵αʹ'); + }); + + it('rejects zero and values over 9999', () => { + expect(greek(0)).toBe(false); + expect(greek(10000)).toBe(false); + expect(greek(-5)).toBe(false); + }); +}); + +describe('formal Chinese and Japanese numerals', () => { + it('uses 大写 financial forms in Chinese', () => { + expect(chinese(42, { formal: true })).toBe('肆拾贰'); + expect(chinese(10, { formal: true })).toBe('壹拾'); + expect(chinese(1001, { formal: true })).toBe('壹仟零壹'); + expect(chinese(123456, { formal: true })).toBe('壹拾贰万叁仟肆佰伍拾陆'); + expect(chinese(100000001, { formal: true })).toBe('壹亿零壹'); + expect(chinese(10001, { formal: true })).toBe('壹万零壹'); + expect(chinese(10010, { formal: true })).toBe('壹万零壹拾'); + expect(chinese(10100, { formal: true })).toBe('壹万零壹佰'); + expect(chinese(0, { formal: true })).toBe('零'); + }); + + it('uses 大字 forms in Japanese', () => { + expect(japanese(42, { formal: true })).toBe('四拾弐'); + expect(japanese(10, { formal: true })).toBe('壱拾'); + expect(japanese(1000, { formal: true })).toBe('壱千'); + expect(japanese(1001, { formal: true })).toBe('壱千壱'); + expect(japanese(123456, { formal: true })).toBe('壱拾弐万参千四百五拾六'); + expect(japanese(0, { formal: true })).toBe('零'); + expect(japanese(0)).toBe('ゼロ'); + }); + + it('is off by default and reachable through toWords', () => { + expect(chinese(42)).toBe('四十二'); + expect(japanese(1000)).toBe('千'); + expect(toWords(42, { lang: 'zh', formal: true })).toBe('肆拾贰'); + expect(toWords(10000, { lang: 'ja', formal: true })).toBe('壱万'); + }); +}); + +describe('language aliases', () => { + it('accepts bahasa for Indonesian', () => { + expect(numberstring(42, { lang: 'bahasa' })).toBe('empat puluh dua'); + }); +}); + +describe('nato', () => { + it('reads digits with ICAO pronunciations', () => { + expect(nato(1984)).toBe('wun niner ait fower'); + expect(nato(42)).toBe('fower too'); + expect(nato(0)).toBe('zero'); + expect(nato('007')).toBe('zero zero seven'); + expect(nato('000')).toBe('zero zero zero'); + expect(nato('0000')).toBe('zero zero zero zero'); + expect(nato('00100')).toBe('zero zero wun zero zero'); + expect(nato('01200')).toBe('zero wun too zero zero'); + expect(nato(10000)).toBe('wun zero tousand'); + }); + + it('reads whole hundreds and thousands as words', () => { + expect(nato(500)).toBe('fife hundred'); + expect(nato(1000)).toBe('wun tousand'); + expect(nato(2500)).toBe('too tousand fife hundred'); + expect(nato(11000)).toBe('wun wun tousand'); + expect(nato(25000)).toBe('too fife tousand'); + expect(nato(100000)).toBe('wun zero zero zero zero zero'); + }); + + it('handles decimals, negatives, BigInt, and options', () => { + expect(nato(3.14)).toBe('tree decimal wun fower'); + expect(nato('123.45')).toBe('wun too tree decimal fower fife'); + expect(nato(-7)).toBe('minus seven'); + expect(nato(10n ** 3n)).toBe('wun tousand'); + expect(nato(2500, { digits: true })).toBe('too fife zero zero'); + expect(nato(1984, { cap: 'upper' })).toBe('WUN NINER AIT FOWER'); + }); + + it('is also exported as icao and military', () => { + expect(icao).toBe(nato); + expect(military).toBe(nato); + }); + + it('rejects invalid input', () => { + expect(nato('abc')).toBe(false); + expect(nato(NaN)).toBe(false); + expect(nato(Infinity)).toBe(false); + expect(nato(null)).toBe(false); + }); +}); + +describe('morse', () => { + it('encodes digits', () => { + expect(morse(0)).toBe('-----'); + expect(morse(42)).toBe('....- ..---'); + expect(morse('1984')).toBe('.---- ----. ---.. ....-'); + expect(morse(10n ** 3n)).toBe('.---- ----- ----- -----'); + }); + + it('encodes point and minus', () => { + expect(morse(3.1)).toBe('...-- .-.-.- .----'); + expect(morse(-5)).toBe('-....- .....'); + }); + + it('rejects invalid input', () => { + expect(morse('sos')).toBe(false); + expect(morse(NaN)).toBe(false); + expect(morse(Infinity)).toBe(false); + }); +}); + +describe('telephone oh option', () => { + it('says oh for zero', () => { + expect(telephone('555-0100', { oh: true })).toBe('five five five oh one oh oh'); + expect(telephone('555-0100')).toBe('five five five zero one zero zero'); + expect(telephone(8675309, { oh: true, cap: 'title' })).toBe('Eight Six Seven Five Three Oh Nine'); + }); +}); + +describe('scientific', () => { + it('formats with a superscript exponent by default', () => { + expect(scientific(1984)).toBe('1.984 × 10³'); + expect(scientific(42)).toBe('4.2 × 10¹'); + expect(scientific(1)).toBe('1 × 10⁰'); + expect(scientific(0)).toBe('0 × 10⁰'); + expect(scientific(100)).toBe('1 × 10²'); + expect(scientific(0.00042)).toBe('4.2 × 10⁻⁴'); + expect(scientific('0.5')).toBe('5 × 10⁻¹'); + expect(scientific(-1500)).toBe('-1.5 × 10³'); + }); + + it('keeps an exact mantissa for floats, BigInt, and exponent-form input', () => { + expect(scientific(1.5e-7)).toBe('1.5 × 10⁻⁷'); + expect(scientific(-1.5e-7)).toBe('-1.5 × 10⁻⁷'); + expect(scientific(1e21)).toBe('1 × 10²¹'); + expect(scientific(6.02214076e23)).toBe('6.02214076 × 10²³'); + expect(scientific(10n ** 36n - 1n)).toBe('1 × 10³⁶'); + expect(scientific(123456789012345)).toBe('1.23456789012 × 10¹⁴'); + }); + + it('rounds to the requested significant digits with carry', () => { + expect(scientific(1984, { digits: 2 })).toBe('2 × 10³'); + expect(scientific(1984, { digits: 3 })).toBe('1.98 × 10³'); + expect(scientific(999, { digits: 2 })).toBe('1 × 10³'); + }); + + it('supports caret, e, and words formats', () => { + expect(scientific(1984, { format: 'caret' })).toBe('1.984 × 10^3'); + expect(scientific(1984, { format: 'e' })).toBe('1.984e3'); + expect(scientific(0.00042, { format: 'e' })).toBe('4.2e-4'); + expect(scientific(1984, { format: 'words' })).toBe('one point nine eight four times ten to the third'); + expect(scientific(0.00042, { format: 'words' })).toBe('four point two times ten to the negative fourth'); + expect(scientific(1, { format: 'words' })).toBe('one'); + expect(scientific(-0.00042, { format: 'words', cap: 'title' })).toBe('Negative Four Point Two Times Ten To The Negative Fourth'); + }); + + it('rejects invalid input and formats', () => { + expect(scientific('abc')).toBe(false); + expect(scientific(NaN)).toBe(false); + expect(scientific(Infinity)).toBe(false); + expect(scientific(1, { format: 'latex' })).toBe(false); + }); +}); + +describe('radix, binary, octal, hex', () => { + it('converts integers between bases', () => { + expect(binary(42)).toBe('101010'); + expect(octal(42)).toBe('52'); + expect(hex(42)).toBe('2a'); + expect(radix(42, 36)).toBe('16'); + expect(hex('255')).toBe('ff'); + expect(binary(10n ** 20n)).toBe((10n ** 20n).toString(2)); + }); + + it('supports prefix, upper, pad, and negatives', () => { + expect(hex(255, { prefix: true, upper: true })).toBe('0xFF'); + expect(binary(-5, { prefix: true })).toBe('-0b101'); + expect(binary(5, { pad: 8 })).toBe('00000101'); + expect(octal(8, { prefix: true })).toBe('0o10'); + expect(radix(42, 36, { prefix: true })).toBe('16'); + }); + + it('rejects bad bases and non-integers', () => { + expect(radix(42, 1)).toBe(false); + expect(radix(42, 37)).toBe(false); + expect(radix(1.5)).toBe(false); + expect(radix('x')).toBe(false); + expect(binary(NaN)).toBe(false); + }); +}); + +describe('bytes', () => { + it('uses decimal units by default', () => { + expect(bytes(0)).toBe('0 B'); + expect(bytes(999)).toBe('999 B'); + expect(bytes(1000)).toBe('1 KB'); + expect(bytes(1536)).toBe('1.5 KB'); + expect(bytes(1048576)).toBe('1 MB'); + expect(bytes(1536000)).toBe('1.5 MB'); + expect(bytes(10n ** 15n)).toBe('1 PB'); + expect(bytes(999999)).toBe('1 MB'); + }); + + it('uses binary units on request', () => { + expect(bytes(1536, { binary: true })).toBe('1.5 KiB'); + expect(bytes(1048576, { binary: true })).toBe('1 MiB'); + expect(bytes(1023, { binary: true })).toBe('1023 B'); + }); + + it('spells out long form with plurals', () => { + expect(bytes(1536, { long: true })).toBe('one point five kilobytes'); + expect(bytes(1, { long: true })).toBe('one byte'); + expect(bytes(1000, { long: true })).toBe('one kilobyte'); + expect(bytes(1048576, { binary: true, long: true })).toBe('one mebibyte'); + }); + + it('honors digits and rejects invalid input', () => { + expect(bytes(1536, { digits: 0 })).toBe('2 KB'); + expect(bytes(1234567890, { digits: 6 })).toBe('1.234568 GB'); + expect(bytes(1234567890, { digits: 3 })).toBe('1.235 GB'); + expect(bytes(100000000000123456000000000000000000n, { digits: 6 })).toBe('100000000000.123456 YB'); + expect(bytes(10n ** 30n)).toBe('1000000 YB'); + expect(bytes(1999, { digits: 0 })).toBe('2 KB'); + expect(bytes(999999999, { digits: 2 })).toBe('1 GB'); + expect(bytes(1536, { digits: 1.5 })).toBe('1.5 KB'); + expect(bytes(1536, { digits: NaN })).toBe('1.5 KB'); + expect(bits(1536, { digits: Infinity })).toBe('1.5 kb'); + expect(compact(1536, { digits: 2.7 })).toBe('1.54K'); + expect(scientific(1984, { digits: 2.9 })).toBe('2 × 10³'); + expect(bytes(-1)).toBe(false); + expect(bytes(1.5)).toBe(false); + expect(bytes('abc')).toBe(false); + }); +}); + +describe('bits', () => { + it('uses bandwidth-style units', () => { + expect(bits(0)).toBe('0 b'); + expect(bits(999)).toBe('999 b'); + expect(bits(1000)).toBe('1 kb'); + expect(bits(1500000)).toBe('1.5 Mb'); + expect(bits(10n ** 9n)).toBe('1 Gb'); + }); + + it('supports binary units and long form', () => { + expect(bits(1536, { binary: true })).toBe('1.5 Kib'); + expect(bits(1500000, { long: true })).toBe('one point five megabits'); + expect(bits(1, { long: true })).toBe('one bit'); + expect(bits(1048576, { binary: true, long: true })).toBe('one mebibit'); + }); + + it('rejects invalid input', () => { + expect(bits(-1)).toBe(false); + expect(bits(2.5)).toBe(false); + }); +}); + +describe('clock', () => { + it('maps hours to clock faces', () => { + expect(clock(1)).toBe('🕐'); + expect(clock(3)).toBe('🕒'); + expect(clock(12)).toBe('🕛'); + expect(clock(0)).toBe('🕛'); + expect(clock(24)).toBe('🕛'); + expect(clock(15)).toBe('🕒'); + }); + + it('rounds H:MM to the nearest half hour', () => { + expect(clock('3:30')).toBe('🕞'); + expect(clock('3:14')).toBe('🕒'); + expect(clock('3:15')).toBe('🕞'); + expect(clock('12:44')).toBe('🕧'); + expect(clock('12:45')).toBe('🕐'); + expect(clock('23:50')).toBe('🕛'); + expect(clock('0:30')).toBe('🕧'); + }); + + it('rejects invalid input', () => { + expect(clock(25)).toBe(false); + expect(clock(1.5)).toBe(false); + expect(clock('x')).toBe(false); + expect(clock('3:60')).toBe(false); + expect(clock(null)).toBe(false); + }); +}); + +describe('cap casing styles', () => { + it('joins words for code-style casing', () => { + expect(numberstring(123, { cap: 'camel' })).toBe('oneHundredTwentyThree'); + expect(numberstring(123, { cap: 'pascal' })).toBe('OneHundredTwentyThree'); + expect(numberstring(123, { cap: 'snake' })).toBe('one_hundred_twenty_three'); + expect(numberstring(123, { cap: 'kebab' })).toBe('one-hundred-twenty-three'); + expect(numberstring(123, { cap: 'hyphen' })).toBe('one-hundred-twenty-three'); + expect(numberstring(123, { cap: 'constant' })).toBe('ONE_HUNDRED_TWENTY_THREE'); + expect(numberstring(123, { cap: 'screaming' })).toBe('ONE_HUNDRED_TWENTY_THREE'); + expect(numberstring(123, { cap: 'dot' })).toBe('one.hundred.twenty.three'); + }); + + it('sentence case capitalizes only the first letter', () => { + expect(numberstring(123, { cap: 'sentence' })).toBe('One hundred twenty-three'); + expect(numberstring(-5, { cap: 'sentence' })).toBe('Negative five'); + }); + + it('works through delegated paths and other helpers', () => { + expect(numberstring(-3.5, { cap: 'snake' })).toBe('negative_three_point_five'); + expect(numberstring(-123, { cap: 'camel' })).toBe('negativeOneHundredTwentyThree'); + expect(numberstring(-123, { cap: 'pascal' })).toBe('NegativeOneHundredTwentyThree'); + expect(numberstring(-5n, { cap: 'title', punc: '!' })).toBe('Negative Five!'); + expect(numberstring('42', { cap: 'camel', punc: '!' })).toBe('fortyTwo!'); + expect(numberstring(42, { lang: 'es', cap: 'kebab' })).toBe('cuarenta-y-dos'); + expect(numberstring(1001, { and: true, cap: 'constant' })).toBe('ONE_THOUSAND_AND_ONE'); + expect(ordinal(21, { cap: 'camel' })).toBe('twentyFirst'); + expect(decimal(3.14, { cap: 'pascal' })).toBe('ThreePointOneFour'); + expect(currency('$1.50', { cap: 'snake' })).toBe('one_dollar_and_fifty_cents'); + expect(nato(1984, { cap: 'kebab' })).toBe('wun-niner-ait-fower'); + }); + + it('leaves unknown styles alone and exports the list', () => { + expect(numberstring(42, { cap: 'wingdings' })).toBe('forty-two'); + expect(CAP_STYLES).toContain('camel'); + expect(CAP_STYLES).toContain('snake'); + expect(CAP_STYLES).toContain('hyphen'); + expect(CAP_STYLES).toContain('screaming'); + expect(roman(4, { lower: true })).toBe('iv'); + }); +}); + +describe('year beyond 9999', () => { + it('reads far-future years as cardinals', () => { + expect(year(10000)).toBe('ten thousand'); + expect(year(8675309)).toBe('eight million six hundred seventy-five thousand three hundred nine'); + expect(year(10n ** 6n)).toBe('one million'); + expect(year(1984n)).toBe('nineteen eighty-four'); + expect(year(12345, { cap: 'title' })).toBe('Twelve Thousand Three Hundred Forty-Five'); + }); + + it('still rejects negatives and non-integers', () => { + expect(year(-1)).toBe(false); + expect(year(-1n)).toBe(false); + expect(year(1.5)).toBe(false); + }); +}); diff --git a/test/fuzz.test.js b/test/fuzz.test.js new file mode 100644 index 0000000..cc3d145 --- /dev/null +++ b/test/fuzz.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import * as lib from '../index.js'; + +/** + * Every public function must return a string, number, bigint, or false for + * any input and any options object. Nothing may throw. This is the contract + * the README promises, and the one that lets callers skip try/catch. + */ + +const INPUTS = [ + 0, -0, 1, -1, 0.1, -0.5, 1e21, -1e21, 1e-7, NaN, Infinity, -Infinity, + Number.MAX_SAFE_INTEGER, Number.MAX_VALUE, Number.MIN_VALUE, + 0n, -1n, 10n ** 36n, 10n ** 37n, -(10n ** 36n), + '', '0', '-0', '00', '1e5', 'abc', '٤٢', '42abc', ' 42 ', '1,000', '1.2.3', '.5', '5.', '-', '--5', '🕒', + null, undefined, true, false, {}, [], [42], () => 1, Symbol('x') +]; + +const OPTIONS = [ + undefined, null, {}, { cap: 'camel' }, { cap: 'nope' }, { cap: 42 }, { lang: 'es' }, { lang: 'xx' }, { lang: 42 }, + { and: true }, { formal: true }, { punc: '!' }, { punc: 42 }, { point: 'dot' }, { digits: 99 }, { digits: -1 }, + { format: 'words' }, { format: 'zzz' }, { binary: true, long: true }, { prefix: true, pad: 100 }, { lower: true }, + { vertical: true }, { oh: true } +]; + +const RESULT_TYPES = new Set(['string', 'number', 'bigint', 'boolean']); + +describe('fuzz: no public function throws', () => { + const fns = Object.entries(lib).filter(([, v]) => typeof v === 'function'); + + it.each(fns.map(([name]) => name))('%s', (name) => { + const fn = lib[name]; + for (const input of INPUTS) { + for (const opt of OPTIONS) { + let result; + expect(() => { result = fn(input, opt); }).not.toThrow(); + expect(RESULT_TYPES.has(typeof result)).toBe(true); + if (typeof result === 'boolean') expect(result).toBe(false); + } + } + }); + + it('fraction and radix also survive hostile second arguments', () => { + for (const a of INPUTS) { + for (const b of INPUTS) { + expect(() => lib.fraction(a, b)).not.toThrow(); + expect(() => lib.radix(a, b)).not.toThrow(); + } + } + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 2c21015..e1bb6e8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -594,8 +594,30 @@ describe('roman', () => { expect(roman(-1)).toBe(false); }); - it('returns false for numbers over 3999', () => { - expect(roman(4000)).toBe(false); + it('uses vinculum notation above 3999', () => { + const bar = '\u0305'; + const dbl = '\u033F'; + expect(roman(4000)).toBe(`I${bar}V${bar}`); + expect(roman(4001)).toBe(`I${bar}V${bar}I`); + expect(roman(1000000)).toBe(`M${bar}`); + expect(roman(3999999)).toBe(`M${bar}M${bar}M${bar}C${bar}M${bar}X${bar}C${bar}I${bar}X${bar}CMXCIX`); + expect(roman(4000000)).toBe(`I${dbl}V${dbl}`); + expect(roman(8675309)).toBe(`V${dbl}I${dbl}I${dbl}I${dbl}D${bar}C${bar}L${bar}X${bar}X${bar}V${bar}CCCIX`); + expect(roman(1000000000)).toBe(`M${dbl}`); + expect(roman(3999999999)).toMatch(/^M\u033FM\u033FM\u033F/); + }); + + it('skips empty middle groups', () => { + expect(roman(1000001)).toBe('M\u0305I'); + expect(roman(2000000000)).toBe('M\u033FM\u033F'); + }); + + it('lowercases barred numerals', () => { + expect(roman(4000, { lower: true })).toBe('i\u0305v\u0305'); + }); + + it('returns false above 3,999,999,999', () => { + expect(roman(4000000000)).toBe(false); }); it('returns false for non-integers', () => { @@ -775,6 +797,10 @@ describe('chinese', () => { it('handles zeros in the middle', () => { expect(chinese(101)).toBe('一百零一'); expect(chinese(1001)).toBe('一千零一'); + expect(chinese(10001)).toBe('一万零一'); + expect(chinese(10010)).toBe('一万零一十'); + expect(chinese(100000001)).toBe('一亿零一'); + expect(chinese(100010000)).toBe('一亿零一万'); }); it('converts thousands and wan', () => { @@ -976,7 +1002,7 @@ describe('year', () => { it('returns false for invalid years', () => { expect(year(-1)).toBe(false); - expect(year(10000)).toBe(false); + expect(year(10000)).toBe('ten thousand'); expect(year(3.14)).toBe(false); }); });