From fc05818f611d55f764754b3f1b79b0eeb0dbf57b Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Fri, 2 Oct 2026 10:49:45 -0400 Subject: [PATCH 01/24] chore: PNG social preview, og regen script, repo metadata - Render site/og.png from og.svg (Slack, X, LinkedIn ignore SVG og:image) - Point og:image / twitter:image at the PNG - Add npm run site:og (scripts/build-og.sh, headless Chrome) - GitHub repo description, homepage and topics set via gh repo edit --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- package.json | 3 ++- scripts/build-og.sh | 10 ++++++++++ scripts/serve-site.js | 1 + site/index.html | 6 +++--- site/og.png | Bin 0 -> 65525 bytes 7 files changed, 18 insertions(+), 6 deletions(-) create mode 100755 scripts/build-og.sh create mode 100644 site/og.png diff --git a/CHANGELOG.md b/CHANGELOG.md index ce73f68..c596d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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. +- Open Graph and Twitter card tags on the playground, with a PNG preview image (`site/og.png`, rendered from `site/og.svg`). - `npm run site` and `npm run site:build` scripts. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6326f68..17cb3b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Key constants: - `index.js` - core English conversion plus all public helpers; `numberstring()` is forgiving and delegates to `negative()`, `decimal()`, `toWords()` - `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 diff --git a/package.json b/package.json index b6a61ef..d1e4e1a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "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", 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/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/index.html b/site/index.html index 0f95766..fbe8864 100644 --- a/site/index.html +++ b/site/index.html @@ -12,14 +12,14 @@ - - + + - + diff --git a/site/og.png b/site/og.png new file mode 100644 index 0000000000000000000000000000000000000000..8aa7f7addddc6d23ddddbf917acf6508db0d6874 GIT binary patch literal 65525 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 h27w3Y|5d;!&u*MV2Mt1ycfd2x-h Date: Tue, 6 Oct 2026 00:30:06 -0400 Subject: [PATCH 02/24] fix(site): restore exact ASCII art header from index.js --- site/index.html | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/site/index.html b/site/index.html index fbe8864..eecc6d3 100644 --- a/site/index.html +++ b/site/index.html @@ -27,17 +27,17 @@
+ /## /"" /"" + | ## | "" |__/ + /####### /## /## /######/#### | ####### /###### /###### /""""""" /"""""" /"""""" /"" /""""""" /"""""" +| ##__ ##| ## | ##| ##_ ##_ ##| ##__ ## /##__ ## /##__ ## /""_____/|_ ""_/ /""__ ""| ""| ""__ "" /""__ "" +| ## \ ##| ## | ##| ## \ ## \ ##| ## \ ##| ########| ## \__/| """""" | "" | "" \__/| ""| "" \ ""| "" \ "" +| ## | ##| ## | ##| ## | ## | ##| ## | ##| ##_____/| ## \____ "" | "" /""| "" | ""| "" | ""| "" | "" +| ## | ##| ######/| ## | ## | ##| #######/| #######| ## /"""""""/ | """"/| "" | ""| "" | ""| """"""" +|__/ |__/ \______/ |__/ |__/ |__/|_______/ \_______/|__/ |_______/ \___/ |__/ |__/|__/ |__/ \____ "" + /"" \ "" + | """"""/ + \______/

numberstring

Number One Way to Makes Words from Numbers

From 1ffda3cdcd8d93364c1d730853242b1956c0a102 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:31:45 -0400 Subject: [PATCH 03/24] fix(site): stop per-line centering skewing the ASCII art --- site/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/style.css b/site/style.css index 3346dc7..5fd8514 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 { From dc61c6cc67daf542e3d7af8a061db7ac3e4f8c01 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:34:13 -0400 Subject: [PATCH 04/24] feat: vinculum notation in roman() up to 3,999,999,999 --- CHANGELOG.md | 1 + README.md | 4 +++- index.d.ts | 2 +- index.js | 43 ++++++++++++++++++++++++++++++++++++++----- site/app.js | 2 +- test/index.test.js | 26 ++++++++++++++++++++++++-- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c596d62..3da15d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ 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`. +- `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`). - `npm run site` and `npm run site:build` scripts. diff --git a/README.md b/README.md index 3db9a3c..005c6eb 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,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 +118,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)` diff --git a/index.d.ts b/index.d.ts index 3554c1f..55c9520 100644 --- a/index.d.ts +++ b/index.d.ts @@ -86,7 +86,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. */ diff --git a/index.js b/index.js index e15680b..47f7215 100644 --- a/index.js +++ b/index.js @@ -446,19 +446,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; }; diff --git a/site/app.js b/site/app.js index 0c9dd88..364292b 100644 --- a/site/app.js +++ b/site/app.js @@ -73,7 +73,7 @@ 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('roman', smallInt && value >= 1 && value <= 3999999999 ? roman(value) : false); row('year', smallInt && value >= 1000 && value <= 9999 ? 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); diff --git a/test/index.test.js b/test/index.test.js index 2c21015..ba3270c 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', () => { From 892f55b94deab07d271017e1c105674c05671d99 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:34:37 -0400 Subject: [PATCH 05/24] style(site): space out roman numerals so vinculum bars stay distinct --- site/app.js | 5 +++-- site/style.css | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/site/app.js b/site/app.js index 364292b..4b134fd 100644 --- a/site/app.js +++ b/site/app.js @@ -32,10 +32,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'; @@ -73,7 +74,7 @@ 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 <= 3999999999 ? roman(value) : false); + row('roman', smallInt && value >= 1 && value <= 3999999999 ? roman(value) : false, 'roman'); row('year', smallInt && value >= 1000 && value <= 9999 ? 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); diff --git a/site/style.css b/site/style.css index 5fd8514..6de656f 100644 --- a/site/style.css +++ b/site/style.css @@ -121,6 +121,8 @@ 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; } table { width: 100%; border-collapse: collapse; } td { padding: 8px 8px; border-top: 1px solid var(--border); vertical-align: top; } From 6590cd21358dfa1f7271ebb0ce1ec0a276465efe Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:46:32 -0400 Subject: [PATCH 06/24] feat: and option, nth(), compact(), fancy(), ancient numerals, formal zh/ja (v1.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - British 'and' option on numberstring()/ordinal() - nth(): 1st 2nd 3rd 11th 112th - compact(): 1.5K 2.3B, digits and long options - fancy(): circled, superscript, subscript, fullwidth, bold, doublestruck, sans, monospace, keycap, braille digit styles - numerals.js: egyptian, babylonian, mayan, greek, tally - chinese()/japanese() formal option for 大写 / 大字 financial numerals - Playground rows for all of the above; ASCII art links home - Codex fixes: bahasa alias, converter option types, safe hash decode - Types, README, CHANGELOG 1.2.0, version bump; still zero dependencies --- CHANGELOG.md | 25 +++- CLAUDE.md | 4 + README.md | 79 ++++++++++- index.d.ts | 72 +++++++++-- index.js | 121 +++++++++++++++-- languages/index.js | 1 + languages/ja.js | 30 +++-- languages/zh.js | 23 +++- numerals.js | 251 +++++++++++++++++++++++++++++++++++ package.json | 25 ++-- scripts/build-site.js | 3 +- site/app.js | 43 +++++- site/index.html | 2 + site/style.css | 4 + test/extras.test.js | 295 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 925 insertions(+), 53 deletions(-) create mode 100644 numerals.js create mode 100644 test/extras.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da15d3..ff5de6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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 + +- **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, `mayan()` base-20, `greek()` Ionic letters (to 9999), `tally()` marks. +- **Financial numerals** - `chinese(n, { formal: true })` → 壹仟零壹 (大写), `japanese(n, { formal: true })` → 壱千壱 (大字). Also via `toWords(n, { lang: 'zh', formal: true })`. +- `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`). +- `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,8 +37,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`. -- `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`). - `npm run site` and `npm run site:build` scripts. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 17cb3b1..9ae394b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ 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, mayan, greek, tally) and `fancy()` Unicode digit styles; table-driven, re-exported from index.js - `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/`. `og.png` is the social preview; after editing `og.svg` run `npm run site:og` to re-render it @@ -77,6 +78,9 @@ Key constants: - 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` +- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles +- Egyptian, Babylonian, Mayan, Greek, tally numerals; Chinese/Japanese `formal` financial numerals +- **Zero runtime dependencies, always.** Never add a package to `dependencies`. --- diff --git a/README.md b/README.md index 005c6eb..d5e4ead 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. @@ -21,9 +21,11 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **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 +- **Roman numerals** - Classic and vinculum notation to 3,999,999,999 +- **Ancient and alternative numerals** - Egyptian, Babylonian, Mayan, Greek, tally marks, 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** - 700+ tests with 90%+ coverage, including per-language spot checks - **Modern ES modules** - Tree-shakeable, with bundled TypeScript declarations ## Installation @@ -43,6 +45,7 @@ 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' ``` @@ -65,7 +68,7 @@ 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. +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])` @@ -134,6 +137,72 @@ 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`, `braille`. + +```javascript +import { fancy } from 'numberstring'; + +fancy(42); // '④②' +fancy(42, 'superscript'); // '⁴²' +fancy(42, 'doublestruck'); // '𝟜𝟚' +fancy(42, 'keycap'); // '4️⃣2️⃣' +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, mayan, greek, tally } from 'numberstring'; + +egyptian(42); // '𓎆𓎆𓎆𓎆𓏺𓏺' additive, 1 to 9,999,999 +babylonian(42); // '𒌋𒌋𒌋𒌋𒐕𒐕' base 60, places separated by spaces +babylonian(3600); // '𒐕 𒑊 𒑊' +mayan(42); // '𝋢𝋢' base 20, most significant first +mayan(1984, { vertical: true }); // stacked with newlines +greek(42); // 'μβʹ' Ionic letters, 1 to 9999 +greek(1999); // '͵αϡϟθʹ' +tally(7); // '𝍸 𝍷𝍷' groups of five, 0 to 1000 +``` + +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])` @@ -279,6 +348,8 @@ Languages are modular! To add a new language: | `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 55c9520..3130759 100644 --- a/index.d.ts +++ b/index.d.ts @@ -27,7 +27,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' @@ -43,8 +43,29 @@ 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; +} + +export interface MayanOptions { + /** Stack places top to bottom with newlines */ + vertical?: boolean; +} + +/** Unicode digit styles accepted by fancy() */ +export type FancyStyle = + | 'circled' | 'superscript' | 'subscript' | 'fullwidth' | 'bold' + | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'braille'; + export interface CurrencyOptions extends Pick { /** Currency symbol or ISO code when the amount has none: '$', 'USD', '€', 'EUR', '£', 'GBP', '¥', 'JPY', '₹', 'INR', '元', 'CNY' */ currency?: string; @@ -75,10 +96,37 @@ 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; + +/** Mayan base-20 numerals, most significant first */ +export function mayan(n: Numeric, opt?: MayanOptions): string | false; + +/** Greek Ionic alphabetic numerals, 1 to 9999 */ +export function greek(n: Numeric): string | false; + +/** Tally marks in groups of five, 0 to 1000 */ +export function tally(n: Numeric): string | false; /** Decimal words: 3.14 → 'three point one four' */ export function decimal(n: number | string, opt?: Pick): Result; @@ -113,18 +161,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 47f7215..3131fbb 100644 --- a/index.js +++ b/index.js @@ -21,8 +21,11 @@ // 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, mayan, greek, tally } 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, mayan, greek, tally }; // ============================================================================ // CONSTANTS @@ -151,6 +154,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 +173,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]} `; } } @@ -204,10 +213,12 @@ const cardinal = (n, opt) => { * @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' @@ -289,7 +300,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(' '); @@ -566,6 +577,96 @@ const parse = (str) => { return result; }; +// ============================================================================ +// NTH (numeric ordinal suffix) +// ============================================================================ + +/** + * 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 [intPart, fracPart = ''] = str.split('.'); + const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); + + if (intPart.length < 4) { + const small = Number(`${intPart}.${fracPart || '0'}`); + const rounded = Number(small.toFixed(digits)); + return `${negative ? '-' : ''}${rounded}`; + } + + 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)); + if (value >= 1000) { + g++; + if (g >= COMPACT_SUFFIXES.length) return false; + value = Number((value / 1000).toFixed(digits)); + } + + const scale = opt?.long ? ` ${ILLIONS[g]}` : COMPACT_SUFFIXES[g]; + return `${negative ? '-' : ''}${value}${scale}`; +}; + // ============================================================================ // ADDITIONAL UTILITY FUNCTIONS // ============================================================================ @@ -731,7 +832,7 @@ const toWords = (n, opt) => { result = danish(n); break; case 'chinese': - result = chinese(n); + result = chinese(n, opt); break; case 'hindi': result = hindi(n); @@ -743,7 +844,7 @@ const toWords = (n, opt) => { result = portuguese(n); break; case 'japanese': - result = japanese(n); + result = japanese(n, opt); break; case 'korean': result = korean(n); @@ -805,5 +906,7 @@ export { year, telephone, percent, + nth, + compact, 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..9380023 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') { @@ -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..81e196b 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,7 +80,7 @@ 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; @@ -79,7 +88,7 @@ const chinese = (n) => { if (hundreds > 0) { if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[hundreds] + ZH_UNITS[2]; + grpStr += digitWords[hundreds] + unitWords[2]; innerZero = false; } else if (thousands > 0) { innerZero = true; @@ -88,10 +97,10 @@ const chinese = (n) => { if (tens > 0) { if (innerZero && grpStr) 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) { @@ -100,7 +109,7 @@ const chinese = (n) => { if (ones > 0) { if (innerZero && grpStr) grpStr += '零'; - grpStr += ZH_ONES[ones]; + grpStr += digitWords[ones]; } result += grpStr; diff --git a/numerals.js b/numerals.js new file mode 100644 index 0000000..f7db4da --- /dev/null +++ b/numerals.js @@ -0,0 +1,251 @@ +/** + * Alternative numeral systems and Unicode digit styles. + * Everything here is pure, table-driven, and renders with Unicode glyphs, + * so results depend on the viewer having a font that covers the block. + * @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: '.' }, + // 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, 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 +// ============================================================================ + +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(' '); +}; + +// ============================================================================ +// MAYAN +// ============================================================================ + +/** Unicode Mayan numerals 0-19 (U+1D2E0 .. U+1D2F3) */ +const MAYAN_DIGITS = Object.freeze([...'𝋠𝋡𝋢𝋣𝋤𝋥𝋦𝋧𝋨𝋩𝋪𝋫𝋬𝋭𝋮𝋯𝋰𝋱𝋲𝋳']); + +/** + * Mayan vigesimal (base 20) numerals using the Unicode Mayan Numerals block. + * Pure base 20 (the calendar's 18-based third place is not applied). + * Most significant digit first; pass `{ vertical: true }` to stack with + * newlines the way the Maya wrote them. + * @param {number|bigint|string} n - Non-negative integer + * @param {Object} [opt] + * @param {boolean} [opt.vertical] - Join places with newlines + * @returns {string|false} + * + * @example + * mayan(42) // '𝋢𝋢' (2 twenties + 2) + * mayan(0) // '𝋠' + */ +const mayan = (n, opt) => { + const count = toCount(n); + if (count === null) return false; + if (count === 0n) return MAYAN_DIGITS[0]; + + const places = []; + let rest = count; + while (rest > 0n) { + places.unshift(MAYAN_DIGITS[Number(rest % 20n)]); + rest /= 20n; + } + return places.join(opt?.vertical ? '\n' : ''); +}; + +// ============================================================================ +// 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; +}; + +// ============================================================================ +// TALLY MARKS +// ============================================================================ + +const TALLY_FIVE = '𝍸'; +const TALLY_ONE = '𝍷'; +/** Keep tally output to something a page can show */ +const TALLY_MAX = 1000n; + +/** + * Tally marks in groups of five using the Unicode tally glyphs. + * @param {number|bigint|string} n - Integer from 0 to 1000 + * @returns {string|false} + * + * @example + * tally(7) // '𝍸 𝍷𝍷' + * tally(0) // '' + */ +const tally = (n) => { + const count = toCount(n); + if (count === null || count > TALLY_MAX) return false; + const v = Number(count); + const groups = []; + for (let i = 0; i < Math.floor(v / 5); i++) groups.push(TALLY_FIVE); + if (v % 5) groups.push(TALLY_ONE.repeat(v % 5)); + return groups.join(' '); +}; + +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, mayan, greek, tally }; diff --git a/package.json b/package.json index d1e4e1a..8af96f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "numberstring", - "version": "1.1.0", + "version": "1.2.0", "description": "Number One Way to Makes Words from Numbers", "type": "module", "main": "index.js", @@ -26,17 +26,26 @@ "url": "git+https://github.com/brianfunk/numberstring.git" }, "keywords": [ - "number", - "string", - "word", - "words", - "integer", "bigint", - "text", + "braille", + "compact", "convert", + "cuneiform", + "decillion", "english", + "hieroglyphs", + "i18n", + "integer", + "mayan", + "multilingual", + "number", + "ordinal", "quintillion", - "decillion" + "roman", + "string", + "text", + "word", + "words" ], "author": "Brian Funk", "license": "MIT", 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/site/app.js b/site/app.js index 4b134fd..932c0a8 100644 --- a/site/app.js +++ b/site/app.js @@ -1,5 +1,6 @@ import numberstring, { - comma, ordinal, roman, year, currency, telephone, fraction + comma, ordinal, roman, year, currency, telephone, fraction, + nth, compact, fancy, egyptian, babylonian, mayan, greek, tally, chinese, japanese } from './lib/index.js'; const LANGS = [ @@ -46,6 +47,24 @@ const row = (label, text, cls) => { 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(); @@ -79,8 +98,22 @@ const render = (raw) => { 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('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('shout', numberstring(parsed.str, { cap: 'upper', punc: '!' })); + 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('keycap', fancy(parsed.str, 'keycap')); + row('braille', fancy(parsed.str, 'braille')); + row('egyptian', wholeInt ? egyptian(value) : false, 'glyphs'); + row('babylonian', wholeInt ? babylonian(value) : false, 'glyphs'); + row('mayan', wholeInt ? mayan(value) : false, 'glyphs'); + row('greek', wholeInt ? greek(value) : false, 'glyphs'); + row('tally', wholeInt ? tally(value) : false, 'glyphs'); for (const [code, name] of LANGS) { const tr = document.createElement('tr'); @@ -97,6 +130,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)); } }; @@ -109,7 +143,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 eecc6d3..8251eea 100644 --- a/site/index.html +++ b/site/index.html @@ -26,6 +26,7 @@
+ +

numberstring

Number One Way to Makes Words from Numbers

diff --git a/site/style.css b/site/style.css index 6de656f..ed13e4a 100644 --- a/site/style.css +++ b/site/style.css @@ -123,6 +123,10 @@ h2 { .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..51870f0 --- /dev/null +++ b/test/extras.test.js @@ -0,0 +1,295 @@ +import { describe, it, expect } from 'vitest'; +import numberstring, { + ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, + egyptian, babylonian, mayan, greek, tally, + 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('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'); + }); + + 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('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️⃣', + 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('−④②'); + }); + + 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('mayan', () => { + it('writes base-20 digits most significant first', () => { + expect(mayan(0)).toBe('𝋠'); + expect(mayan(19)).toBe('𝋳'); + expect(mayan(20)).toBe('𝋡𝋠'); + expect(mayan(42)).toBe('𝋢𝋢'); + expect(mayan(400)).toBe('𝋡𝋠𝋠'); + expect(mayan(1984)).toBe('𝋤𝋳𝋤'); + }); + + it('stacks vertically on request', () => { + expect(mayan(1984, { vertical: true })).toBe('𝋤\n𝋳\n𝋤'); + }); + + it('rejects invalid input', () => { + expect(mayan(-1)).toBe(false); + expect(mayan(2.5)).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('tally', () => { + it('groups by five', () => { + expect(tally(0)).toBe(''); + expect(tally(1)).toBe('𝍷'); + expect(tally(4)).toBe('𝍷𝍷𝍷𝍷'); + expect(tally(5)).toBe('𝍸'); + expect(tally(7)).toBe('𝍸 𝍷𝍷'); + expect(tally(12)).toBe('𝍸 𝍸 𝍷𝍷'); + }); + + it('rejects negatives, fractions, and more than 1000', () => { + expect(tally(-1)).toBe(false); + expect(tally(1.5)).toBe(false); + expect(tally(1001)).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(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('壱拾弐万参千四百五拾六'); + }); + + 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'); + }); +}); From 3448cefc1a2d2c7c66415068748a9b23223fc028 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:51:48 -0400 Subject: [PATCH 07/24] fix: address Codex review on 1.2.0 features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compact(): ignore leading zeros when picking a scale; keep 1000Dc at the upper bound instead of returning false - decimal(): forward the 'and' option to the integer part - chinese(): emit 零 between a higher myriad group and a lower group that starts below the thousands place (一万零一), formal and plain --- index.js | 10 ++++++---- languages/zh.js | 6 +++--- test/extras.test.js | 19 +++++++++++++++++++ test/index.test.js | 4 ++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 3131fbb..fe9150b 100644 --- a/index.js +++ b/index.js @@ -369,7 +369,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 ' : ''; @@ -643,7 +643,9 @@ const compact = (n, opt) => { const negative = str.startsWith('-'); if (negative) str = str.slice(1); - const [intPart, fracPart = ''] = str.split('.'); + const [rawInt, fracPart = ''] = str.split('.'); + // Leading zeros carry no magnitude: '0001000' is 1000 + const intPart = rawInt.replace(/^0+(?=\d)/, ''); const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); if (intPart.length < 4) { @@ -657,9 +659,9 @@ const compact = (n, opt) => { // 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)); - if (value >= 1000) { + // Rounding can carry into the next scale; at the last scale keep 1000Dc + if (value >= 1000 && g + 1 < COMPACT_SUFFIXES.length) { g++; - if (g >= COMPACT_SUFFIXES.length) return false; value = Number((value / 1000).toFixed(digits)); } diff --git a/languages/zh.js b/languages/zh.js index 81e196b..6c3a28c 100644 --- a/languages/zh.js +++ b/languages/zh.js @@ -87,7 +87,7 @@ const chinese = (n, opt) => { } if (hundreds > 0) { - if (innerZero && grpStr) grpStr += '零'; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; grpStr += digitWords[hundreds] + unitWords[2]; innerZero = false; } else if (thousands > 0) { @@ -95,7 +95,7 @@ const chinese = (n, opt) => { } 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 && !formal && !result && thousands === 0 && hundreds === 0) { grpStr += unitWords[1]; @@ -108,7 +108,7 @@ const chinese = (n, opt) => { } if (ones > 0) { - if (innerZero && grpStr) grpStr += '零'; + if (innerZero && (grpStr || (result && !result.endsWith('零')))) grpStr += '零'; grpStr += digitWords[ones]; } diff --git a/test/extras.test.js b/test/extras.test.js index 51870f0..bd063f4 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -29,6 +29,11 @@ describe('and option (British style)', () => { 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'); @@ -110,6 +115,17 @@ describe('compact', () => { 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); @@ -269,6 +285,9 @@ describe('formal Chinese and Japanese numerals', () => { 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('零'); }); diff --git a/test/index.test.js b/test/index.test.js index ba3270c..315780b 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -797,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', () => { From b4cde3075ca85f87250356dfe5fc263a745df131 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:52:27 -0400 Subject: [PATCH 08/24] chore: drop mayan() and tally(); no system font coverage on macOS --- CHANGELOG.md | 2 +- CLAUDE.md | 4 +-- README.md | 7 ++--- index.d.ts | 10 +------ index.js | 4 +-- numerals.js | 70 +++------------------------------------------ site/app.js | 4 +-- test/extras.test.js | 39 +------------------------ 8 files changed, 14 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5de6e..d7ac3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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, `mayan()` base-20, `greek()` Ionic letters (to 9999), `tally()` marks. +- **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 })`. - `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`). diff --git a/CLAUDE.md b/CLAUDE.md index 9ae394b..ead914d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ 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, mayan, greek, tally) and `fancy()` Unicode digit styles; table-driven, re-exported from index.js +- `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/`. `og.png` is the social preview; after editing `og.svg` run `npm run site:og` to re-render it @@ -79,7 +79,7 @@ Key constants: - BigInt support up to 10^36 - Forgiving input: `numberstring(-3.14)`, `numberstring('42')`, `numberstring(42, { lang: 'de' })` all work; invalid input returns `false` - British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles -- Egyptian, Babylonian, Mayan, Greek, tally numerals; Chinese/Japanese `formal` financial numerals +- Egyptian, Babylonian, Greek numerals; Chinese/Japanese `formal` financial numerals - **Zero runtime dependencies, always.** Never add a package to `dependencies`. --- diff --git a/README.md b/README.md index d5e4ead..b934166 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **Huge range** - Supports 0 to decillions (10^36) with BigInt - **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers - **Roman numerals** - Classic and vinculum notation to 3,999,999,999 -- **Ancient and alternative numerals** - Egyptian, Babylonian, Mayan, Greek, tally marks, Chinese/Japanese financial forms +- **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** - 700+ tests with 90%+ coverage, including per-language spot checks @@ -182,16 +182,13 @@ fancy(-3.5, 'braille'); // '⠼⠤⠉⠨⠑' All render with Unicode glyphs, so they need a font that covers the block (most modern systems do). ```javascript -import { egyptian, babylonian, mayan, greek, tally } from 'numberstring'; +import { egyptian, babylonian, greek } from 'numberstring'; egyptian(42); // '𓎆𓎆𓎆𓎆𓏺𓏺' additive, 1 to 9,999,999 babylonian(42); // '𒌋𒌋𒌋𒌋𒐕𒐕' base 60, places separated by spaces babylonian(3600); // '𒐕 𒑊 𒑊' -mayan(42); // '𝋢𝋢' base 20, most significant first -mayan(1984, { vertical: true }); // stacked with newlines greek(42); // 'μβʹ' Ionic letters, 1 to 9999 greek(1999); // '͵αϡϟθʹ' -tally(7); // '𝍸 𝍷𝍷' groups of five, 0 to 1000 ``` Chinese and Japanese also have the anti-fraud financial forms used on cheques: diff --git a/index.d.ts b/index.d.ts index 3130759..96b2411 100644 --- a/index.d.ts +++ b/index.d.ts @@ -56,10 +56,6 @@ export interface CompactOptions { long?: boolean; } -export interface MayanOptions { - /** Stack places top to bottom with newlines */ - vertical?: boolean; -} /** Unicode digit styles accepted by fancy() */ export type FancyStyle = @@ -119,14 +115,10 @@ export function egyptian(n: Numeric): string | false; /** Babylonian base-60 cuneiform numerals */ export function babylonian(n: Numeric): string | false; -/** Mayan base-20 numerals, most significant first */ -export function mayan(n: Numeric, opt?: MayanOptions): string | false; - /** Greek Ionic alphabetic numerals, 1 to 9999 */ export function greek(n: Numeric): string | false; -/** Tally marks in groups of five, 0 to 1000 */ -export function tally(n: Numeric): string | false; + /** Decimal words: 3.14 → 'three point one four' */ export function decimal(n: number | string, opt?: Pick): Result; diff --git a/index.js b/index.js index fe9150b..0189898 100644 --- a/index.js +++ b/index.js @@ -21,11 +21,11 @@ // 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'; -import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, mayan, greek, tally } from './numerals.js'; +import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek } 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, mayan, greek, tally }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek }; // ============================================================================ // CONSTANTS diff --git a/numerals.js b/numerals.js index f7db4da..48ba22a 100644 --- a/numerals.js +++ b/numerals.js @@ -1,7 +1,8 @@ /** * Alternative numeral systems and Unicode digit styles. - * Everything here is pure, table-driven, and renders with Unicode glyphs, - * so results depend on the viewer having a font that covers the block. + * 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 */ @@ -152,41 +153,6 @@ const babylonian = (n) => { .join(' '); }; -// ============================================================================ -// MAYAN -// ============================================================================ - -/** Unicode Mayan numerals 0-19 (U+1D2E0 .. U+1D2F3) */ -const MAYAN_DIGITS = Object.freeze([...'𝋠𝋡𝋢𝋣𝋤𝋥𝋦𝋧𝋨𝋩𝋪𝋫𝋬𝋭𝋮𝋯𝋰𝋱𝋲𝋳']); - -/** - * Mayan vigesimal (base 20) numerals using the Unicode Mayan Numerals block. - * Pure base 20 (the calendar's 18-based third place is not applied). - * Most significant digit first; pass `{ vertical: true }` to stack with - * newlines the way the Maya wrote them. - * @param {number|bigint|string} n - Non-negative integer - * @param {Object} [opt] - * @param {boolean} [opt.vertical] - Join places with newlines - * @returns {string|false} - * - * @example - * mayan(42) // '𝋢𝋢' (2 twenties + 2) - * mayan(0) // '𝋠' - */ -const mayan = (n, opt) => { - const count = toCount(n); - if (count === null) return false; - if (count === 0n) return MAYAN_DIGITS[0]; - - const places = []; - let rest = count; - while (rest > 0n) { - places.unshift(MAYAN_DIGITS[Number(rest % 20n)]); - rest /= 20n; - } - return places.join(opt?.vertical ? '\n' : ''); -}; - // ============================================================================ // GREEK (IONIC / MILESIAN) // ============================================================================ @@ -220,32 +186,4 @@ const greek = (n) => { return out + GREEK_KERAIA; }; -// ============================================================================ -// TALLY MARKS -// ============================================================================ - -const TALLY_FIVE = '𝍸'; -const TALLY_ONE = '𝍷'; -/** Keep tally output to something a page can show */ -const TALLY_MAX = 1000n; - -/** - * Tally marks in groups of five using the Unicode tally glyphs. - * @param {number|bigint|string} n - Integer from 0 to 1000 - * @returns {string|false} - * - * @example - * tally(7) // '𝍸 𝍷𝍷' - * tally(0) // '' - */ -const tally = (n) => { - const count = toCount(n); - if (count === null || count > TALLY_MAX) return false; - const v = Number(count); - const groups = []; - for (let i = 0; i < Math.floor(v / 5); i++) groups.push(TALLY_FIVE); - if (v % 5) groups.push(TALLY_ONE.repeat(v % 5)); - return groups.join(' '); -}; - -export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, mayan, greek, tally }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek }; diff --git a/site/app.js b/site/app.js index 932c0a8..d57606d 100644 --- a/site/app.js +++ b/site/app.js @@ -1,6 +1,6 @@ import numberstring, { comma, ordinal, roman, year, currency, telephone, fraction, - nth, compact, fancy, egyptian, babylonian, mayan, greek, tally, chinese, japanese + nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese } from './lib/index.js'; const LANGS = [ @@ -111,9 +111,7 @@ const render = (raw) => { row('braille', fancy(parsed.str, 'braille')); row('egyptian', wholeInt ? egyptian(value) : false, 'glyphs'); row('babylonian', wholeInt ? babylonian(value) : false, 'glyphs'); - row('mayan', wholeInt ? mayan(value) : false, 'glyphs'); row('greek', wholeInt ? greek(value) : false, 'glyphs'); - row('tally', wholeInt ? tally(value) : false, 'glyphs'); for (const [code, name] of LANGS) { const tr = document.createElement('tr'); diff --git a/test/extras.test.js b/test/extras.test.js index bd063f4..44a5908 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import numberstring, { ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, - egyptian, babylonian, mayan, greek, tally, + egyptian, babylonian, greek, chinese, japanese, toWords } from '../index.js'; @@ -222,26 +222,6 @@ describe('babylonian', () => { }); }); -describe('mayan', () => { - it('writes base-20 digits most significant first', () => { - expect(mayan(0)).toBe('𝋠'); - expect(mayan(19)).toBe('𝋳'); - expect(mayan(20)).toBe('𝋡𝋠'); - expect(mayan(42)).toBe('𝋢𝋢'); - expect(mayan(400)).toBe('𝋡𝋠𝋠'); - expect(mayan(1984)).toBe('𝋤𝋳𝋤'); - }); - - it('stacks vertically on request', () => { - expect(mayan(1984, { vertical: true })).toBe('𝋤\n𝋳\n𝋤'); - }); - - it('rejects invalid input', () => { - expect(mayan(-1)).toBe(false); - expect(mayan(2.5)).toBe(false); - }); -}); - describe('greek', () => { it('uses Ionic letters with keraia', () => { expect(greek(1)).toBe('αʹ'); @@ -261,23 +241,6 @@ describe('greek', () => { }); }); -describe('tally', () => { - it('groups by five', () => { - expect(tally(0)).toBe(''); - expect(tally(1)).toBe('𝍷'); - expect(tally(4)).toBe('𝍷𝍷𝍷𝍷'); - expect(tally(5)).toBe('𝍸'); - expect(tally(7)).toBe('𝍸 𝍷𝍷'); - expect(tally(12)).toBe('𝍸 𝍸 𝍷𝍷'); - }); - - it('rejects negatives, fractions, and more than 1000', () => { - expect(tally(-1)).toBe(false); - expect(tally(1.5)).toBe(false); - expect(tally(1001)).toBe(false); - }); -}); - describe('formal Chinese and Japanese numerals', () => { it('uses 大写 financial forms in Chinese', () => { expect(chinese(42, { formal: true })).toBe('肆拾贰'); From b3a7cdde53b67078d1801a6f2858c9883e9f927c Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:54:39 -0400 Subject: [PATCH 09/24] fix(site): show the fraction row for any denominator, not just up to 1000 --- site/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/app.js b/site/app.js index d57606d..ef55cd1 100644 --- a/site/app.js +++ b/site/app.js @@ -97,7 +97,7 @@ const render = (raw) => { row('year', smallInt && value >= 1000 && value <= 9999 ? 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('fraction', smallInt && value >= 2 ? `1/${value} = ${fraction(1, value)}` : false); row('british', wholeInt ? numberstring(value, { and: true }) : false); row('nth', wholeInt ? nth(value) : false); row('compact', compact(parsed.str)); From bc93a2860daa43c8502aea84e658de6005bf07c6 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:55:54 -0400 Subject: [PATCH 10/24] fix: compact() promotes sub-thousand values that round to 1000 into K --- index.js | 4 +++- test/extras.test.js | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 0189898..c6dd1af 100644 --- a/index.js +++ b/index.js @@ -651,7 +651,9 @@ const compact = (n, opt) => { if (intPart.length < 4) { const small = Number(`${intPart}.${fracPart || '0'}`); const rounded = Number(small.toFixed(digits)); - return `${negative ? '-' : ''}${rounded}`; + // 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); diff --git a/test/extras.test.js b/test/extras.test.js index 44a5908..24139f5 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -101,6 +101,11 @@ describe('compact', () => { 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', () => { From c822d9e398ff76c539b9131eb985c138b325868c Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 00:57:50 -0400 Subject: [PATCH 11/24] feat: nato()/icao/military radio numerals, morse(), telephone oh option --- CHANGELOG.md | 3 ++ CLAUDE.md | 2 +- README.md | 31 +++++++++++++-- index.d.ts | 22 +++++++++- index.js | 97 ++++++++++++++++++++++++++++++++++++++++++++- site/app.js | 6 ++- test/extras.test.js | 70 +++++++++++++++++++++++++++++++- 7 files changed, 221 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ac3ee..d6da16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. - `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`). - `bahasa` accepted as an alias for Indonesian. diff --git a/CLAUDE.md b/CLAUDE.md index ead914d..6324e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Key constants: - 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` -- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles +- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()` - Egyptian, Babylonian, Greek numerals; Chinese/Japanese `formal` financial numerals - **Zero runtime dependencies, always.** Never add a package to `dependencies`. diff --git a/README.md b/README.md index b934166..2a96671 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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 +- **Feature-rich** - Ordinals, decimals, currency, fractions, years, phone numbers, NATO/ICAO radio numerals, Morse code - **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️⃣ ⠼⠙⠃ @@ -244,8 +244,33 @@ 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); // '...-- .-.-.- .----' ``` #### `percent(pct, [options])` diff --git a/index.d.ts b/index.d.ts index 96b2411..55bedb4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -141,8 +141,28 @@ export function fraction(numerator: number, denominator: number, 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; /** Percent words: 50 → 'fifty percent' */ export function percent(pct: number | string, opt?: Pick): Result; diff --git a/index.js b/index.js index c6dd1af..d21ebb8 100644 --- a/index.js +++ b/index.js @@ -672,9 +672,97 @@ const compact = (n, opt) => { }; // ============================================================================ -// ADDITIONAL UTILITY FUNCTIONS +// 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 !== '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; +}; + +// ============================================================================ +// 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) => { if (typeof n === 'bigint') { if (n >= 0n) return cardinal(n, opt); @@ -775,7 +863,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(' '); @@ -912,5 +1001,9 @@ export { percent, nth, compact, + nato, + nato as icao, + nato as military, + morse, toWords }; diff --git a/site/app.js b/site/app.js index ef55cd1..bdb97fd 100644 --- a/site/app.js +++ b/site/app.js @@ -1,6 +1,6 @@ import numberstring, { comma, ordinal, roman, year, currency, telephone, fraction, - nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese + nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse } from './lib/index.js'; const LANGS = [ @@ -96,7 +96,9 @@ const render = (raw) => { row('roman', smallInt && value >= 1 && value <= 3999999999 ? roman(value) : false, 'roman'); row('year', smallInt && value >= 1000 && value <= 9999 ? 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('telephone', wholeInt && parsed.magnitude <= 15 ? telephone(parsed.str, { oh: true }) : false); + row('pilot', nato(parsed.str)); + row('morse', morse(parsed.str), 'roman'); row('fraction', smallInt && value >= 2 ? `1/${value} = ${fraction(1, value)}` : false); row('british', wholeInt ? numberstring(value, { and: true }) : false); row('nth', wholeInt ? nth(value) : false); diff --git a/test/extras.test.js b/test/extras.test.js index 24139f5..014f771 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import numberstring, { - ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, + ordinal, nth, compact, fancy, FANCY_STYLE_NAMES, nato, icao, military, morse, telephone, egyptian, babylonian, greek, chinese, japanese, toWords } from '../index.js'; @@ -280,3 +280,71 @@ describe('language aliases', () => { 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(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'); + }); +}); From e3e601f329a8afca6bc9212454cd7bcc3beee056 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:02:37 -0400 Subject: [PATCH 12/24] feat: scientific(), radix/binary/octal/hex, bytes(), clock(); emoji alias; playground tweaks - scientific(): exact mantissa, unicode/caret/e/words formats, digits rounding - radix() with binary/octal/hex helpers; prefix, upper, pad options - bytes(): decimal or binary units, long form - clock(): clock-face emoji for hours or H:MM - fancy('emoji') alias of keycap - Playground: fraction row shows words only, year row from 1, new rows --- CHANGELOG.md | 5 ++ CLAUDE.md | 2 +- README.md | 55 +++++++++++- index.d.ts | 45 +++++++++- index.js | 207 +++++++++++++++++++++++++++++++++++++++++++- numerals.js | 51 ++++++++++- site/app.js | 15 +++- test/extras.test.js | 136 +++++++++++++++++++++++++++++ 8 files changed, 504 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6da16f..c242235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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)`** - `1.5 KB`, `1.5 KiB`, or "one point five kilobytes". +- **`clock(time)`** - Clock-face emoji for an hour or `H:MM`. +- `fancy()` accepts `emoji` as an alias for `keycap`. - `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`). - `bahasa` accepted as an alias for Indonesian. diff --git a/CLAUDE.md b/CLAUDE.md index 6324e0a..bae432f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Key constants: - 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` -- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()` +- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()`, `scientific()`, `binary()`/`octal()`/`hex()`/`radix()`, `bytes()`, `clock()` - Egyptian, Babylonian, Greek numerals; Chinese/Japanese `formal` financial numerals - **Zero runtime dependencies, always.** Never add a package to `dependencies`. diff --git a/README.md b/README.md index 2a96671..9ddbb6d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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, NATO/ICAO radio numerals, Morse code +- **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️⃣ ⠼⠙⠃ @@ -165,7 +165,7 @@ 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`, `braille`. +Digits in a Unicode style: `circled` (default), `superscript`, `subscript`, `fullwidth`, `bold`, `doublestruck`, `sans`, `monospace`, `keycap` (alias `emoji`), `braille`. ```javascript import { fancy } from 'numberstring'; @@ -273,6 +273,57 @@ 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])` + +Human-readable byte sizes. + +```javascript +import { bytes } from 'numberstring'; + +bytes(1536); // '1.5 KB' +bytes(1536, { binary: true }); // '1.5 KiB' +bytes(1536, { long: true }); // 'one point five kilobytes' +``` + +#### `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])` Convert percentages to words. diff --git a/index.d.ts b/index.d.ts index 55bedb4..d49b6e4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -60,7 +60,32 @@ export interface CompactOptions { /** Unicode digit styles accepted by fancy() */ export type FancyStyle = | 'circled' | 'superscript' | 'subscript' | 'fullwidth' | 'bold' - | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'braille'; + | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'emoji' | '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' */ + long?: boolean; +} export interface CurrencyOptions extends Pick { /** Currency symbol or ISO code when the amount has none: '$', 'USD', '€', 'EUR', '£', 'GBP', '¥', 'JPY', '₹', 'INR', '元', 'CNY' */ @@ -164,6 +189,24 @@ 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; + +/** 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; diff --git a/index.js b/index.js index d21ebb8..440a800 100644 --- a/index.js +++ b/index.js @@ -21,11 +21,11 @@ // 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'; -import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek } from './numerals.js'; +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 }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; // ============================================================================ // CONSTANTS @@ -729,6 +729,203 @@ const nato = (n, opt) => { 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 = Math.max(1, Math.min(opt?.digits ?? 12, 36)); + 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(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']); +const BYTE_UNITS_BINARY = Object.freeze(['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']); +const BYTE_WORDS = Object.freeze(['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte', 'zettabyte', 'yottabyte']); +const BYTE_WORDS_BINARY = Object.freeze(['byte', 'kibibyte', 'mebibyte', 'gibibyte', 'tebibyte', 'pebibyte', 'exbibyte', 'zebibyte', 'yobibyte']); + +/** + * 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) => { + 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 ? BYTE_UNITS_BINARY : BYTE_UNITS; + const words = opt?.binary ? BYTE_WORDS_BINARY : BYTE_WORDS; + const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); + + let unit = 0; + let scale = 1n; + while (unit < units.length - 1 && value >= scale * step) { + scale *= step; + unit++; + } + let amount = unit === 0 ? Number(value) : Number((value * 10n ** 6n) / scale) / 1e6; + amount = Number(amount.toFixed(digits)); + if (amount >= Number(step) && unit < units.length - 1) { + unit++; + amount = Number((amount / Number(step)).toFixed(digits)); + } + + if (opt?.long) { + const amountWords = Number.isInteger(amount) ? cardinal(amount) : decimal(amount); + const noun = amount === 1 ? words[unit] : `${words[unit]}s`; + return `${amountWords} ${noun}`; + } + return `${amount} ${units[unit]}`; +}; + // ============================================================================ // MORSE CODE // ============================================================================ @@ -1005,5 +1202,11 @@ export { nato as icao, nato as military, morse, + scientific, + radix, + binary, + octal, + hex, + bytes, toWords }; diff --git a/numerals.js b/numerals.js index 48ba22a..e96a572 100644 --- a/numerals.js +++ b/numerals.js @@ -49,6 +49,7 @@ const FANCY_STYLES = Object.freeze({ 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: '.' }, // Braille: numeric indicator ⠼ then a-j, decimal point ⠨, minus ⠤ braille: { digits: '⠚⠁⠃⠉⠙⠑⠋⠛⠓⠊', minus: '⠤', point: '⠨', prefix: '⠼' } }); @@ -60,7 +61,7 @@ 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, braille + * fullwidth, bold, doublestruck, sans, monospace, keycap (alias emoji), braille * @returns {string|false} Styled digits or false if invalid * * @example @@ -153,6 +154,52 @@ const babylonian = (n) => { .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) // ============================================================================ @@ -186,4 +233,4 @@ const greek = (n) => { return out + GREEK_KERAIA; }; -export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek }; +export { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock }; diff --git a/site/app.js b/site/app.js index bdb97fd..8c61c47 100644 --- a/site/app.js +++ b/site/app.js @@ -1,6 +1,7 @@ import numberstring, { comma, ordinal, roman, year, currency, telephone, fraction, - nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse + nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse, + scientific, binary, octal, hex, bytes, clock } from './lib/index.js'; const LANGS = [ @@ -94,12 +95,17 @@ 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 <= 3999999999 ? roman(value) : false, 'roman'); - row('year', smallInt && value >= 1000 && value <= 9999 ? year(value) : false); + row('year', smallInt && value >= 1 && value <= 9999 ? year(value) : false); row('currency', !negative && typeof value === 'number' && value < 1e15 ? currency(`$${parsed.str}`) : 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)} · ${bytes(value, { binary: true })}` : false); row('morse', morse(parsed.str), 'roman'); - row('fraction', smallInt && value >= 2 ? `1/${value} = ${fraction(1, value)}` : false); + 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)); @@ -109,7 +115,8 @@ const render = (raw) => { row('superscript', fancy(parsed.str, 'superscript')); row('fullwidth', fancy(parsed.str, 'fullwidth')); row('doublestruck', fancy(parsed.str, 'doublestruck')); - row('keycap', fancy(parsed.str, 'keycap')); + row('emoji', fancy(parsed.str, 'emoji')); + row('clock', smallInt && value >= 0 && value <= 24 ? clock(value) : false, 'glyphs'); row('braille', fancy(parsed.str, 'braille')); row('egyptian', wholeInt ? egyptian(value) : false, 'glyphs'); row('babylonian', wholeInt ? babylonian(value) : false, 'glyphs'); diff --git a/test/extras.test.js b/test/extras.test.js index 014f771..d62f231 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -1,6 +1,7 @@ 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, clock, egyptian, babylonian, greek, chinese, japanese, toWords } from '../index.js'; @@ -157,6 +158,7 @@ describe('fancy', () => { sans: '𝟦𝟤', monospace: '𝟺𝟸', keycap: '4️⃣2️⃣', + emoji: '4️⃣2️⃣', braille: '⠼⠙⠃' }; expect([...FANCY_STYLE_NAMES].sort()).toEqual(Object.keys(expected).sort()); @@ -348,3 +350,137 @@ describe('telephone oh option', () => { 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(-1)).toBe(false); + expect(bytes(1.5)).toBe(false); + expect(bytes('abc')).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); + }); +}); From 1d1bbd09e16f91c0d9b5e176000bdaa5062ccbd1 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:03:30 -0400 Subject: [PATCH 13/24] =?UTF-8?q?fix:=20Babylonian=20unit=20wedge=20is=20D?= =?UTF-8?q?I=C5=A0=20(U+12079),=20not=20GESH2;=20nato()=20reads=20all-zero?= =?UTF-8?q?=20strings=20digit=20by=20digit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- index.js | 2 +- numerals.js | 7 ++++--- test/extras.test.js | 18 ++++++++++-------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9ddbb6d..15a9510 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ All render with Unicode glyphs, so they need a font that covers the block (most import { egyptian, babylonian, greek } from 'numberstring'; egyptian(42); // '𓎆𓎆𓎆𓎆𓏺𓏺' additive, 1 to 9,999,999 -babylonian(42); // '𒌋𒌋𒌋𒌋𒐕𒐕' base 60, places separated by spaces -babylonian(3600); // '𒐕 𒑊 𒑊' +babylonian(42); // '𒌋𒌋𒌋𒌋𒁹𒁹' base 60, places separated by spaces +babylonian(3600); // '𒁹 𒑊 𒑊' greek(42); // 'μβʹ' Ionic letters, 1 to 9999 greek(1999); // '͵αϡϟθʹ' ``` diff --git a/index.js b/index.js index 440a800..3e42920 100644 --- a/index.js +++ b/index.js @@ -714,7 +714,7 @@ const nato = (n, opt) => { // 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 !== '0' && intPart.length >= 3 && intPart.length <= 5) { + if (roundMatch && /[1-9]/.test(intPart) && 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'); diff --git a/numerals.js b/numerals.js index e96a572..29aa52b 100644 --- a/numerals.js +++ b/numerals.js @@ -122,7 +122,8 @@ const egyptian = (n) => { // BABYLONIAN CUNEIFORM // ============================================================================ -const CUNEIFORM_ONE = '𒐕'; +/** 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 = '𒑊'; @@ -135,8 +136,8 @@ const CUNEIFORM_ZERO = '𒑊'; * @returns {string|false} * * @example - * babylonian(42) // '𒌋𒌋𒌋𒌋𒐕𒐕' - * babylonian(3600) // '𒐕 𒑊 𒑊' + * babylonian(42) // '𒌋𒌋𒌋𒌋𒁹𒁹' + * babylonian(3600) // '𒁹 𒑊 𒑊' */ const babylonian = (n) => { const count = toCount(n); diff --git a/test/extras.test.js b/test/extras.test.js index d62f231..fae8735 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -208,14 +208,14 @@ describe('egyptian', () => { describe('babylonian', () => { it('writes base-60 places with tens and ones wedges', () => { - expect(babylonian(1)).toBe('𒐕'); + 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('𒌋𒌋𒌋𒐕𒐕𒐕 𒐕𒐕𒐕𒐕'); + 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', () => { @@ -223,7 +223,7 @@ describe('babylonian', () => { }); it('accepts BigInt and rejects invalid input', () => { - expect(babylonian(10n ** 18n)).toMatch(/^𒐕/); + expect(babylonian(10n ** 18n)).toMatch(/^𒁹/); expect(babylonian(-1)).toBe(false); expect(babylonian('x')).toBe(false); }); @@ -289,6 +289,8 @@ describe('nato', () => { 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(10000)).toBe('wun zero tousand'); }); From 3c2409f52ed5b46c915a5e51cfe6867529a860c4 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:05:45 -0400 Subject: [PATCH 14/24] feat: cap casing styles (camel, pascal, snake, kebab, constant, dot, sentence); bits() split from bytes() --- CHANGELOG.md | 3 +- CLAUDE.md | 2 +- README.md | 22 ++++++++-- index.d.ts | 14 +++++-- index.js | 98 +++++++++++++++++++++++++++++++++++---------- site/app.js | 13 +++++- test/extras.test.js | 60 ++++++++++++++++++++++++++- 7 files changed, 178 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c242235..9c23096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. @@ -20,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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)`** - `1.5 KB`, `1.5 KiB`, or "one point five kilobytes". +- **`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`. - `roman()` now supports vinculum notation above 3999 (a bar multiplies by 1000, two bars by a million), up to 3,999,999,999. diff --git a/CLAUDE.md b/CLAUDE.md index bae432f..68abdc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Key constants: - 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` -- British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()`, `scientific()`, `binary()`/`octal()`/`hex()`/`radix()`, `bytes()`, `clock()` +- `cap` casing styles (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot); British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()`, `scientific()`, `binary()`/`octal()`/`hex()`/`radix()`, `bytes()`/`bits()`, `clock()` - Egyptian, Babylonian, Greek numerals; Chinese/Japanese `formal` financial numerals - **Zero runtime dependencies, always.** Never add a package to `dependencies`. diff --git a/README.md b/README.md index 15a9510..4f1d3e1 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,18 @@ numberstring(100, { punc: '!' }); // 'one hundred!' numberstring('abc'); // false ``` +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])` @@ -300,16 +312,18 @@ binary(5, { pad: 8 }); // '00000101' radix(42, 36); // '16' ``` -#### `bytes(n, [options])` +#### `bytes(n, [options])` and `bits(n, [options])` -Human-readable byte sizes. +Human-readable data sizes. Bytes use `KB`/`KiB`, bits use bandwidth-style `kb`/`Mb`. ```javascript -import { bytes } from 'numberstring'; +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)` @@ -417,7 +431,7 @@ Languages are modular! To add a new language: | 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'`) | diff --git a/index.d.ts b/index.d.ts index d49b6e4..632eff1 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 = '!' | '?' | '.'; @@ -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; @@ -83,7 +88,7 @@ export interface BytesOptions { binary?: boolean; /** Maximum decimal places (default 1) */ digits?: number; - /** Spell it out: 'one point five kilobytes' */ + /** Spell it out: 'one point five kilobytes' / 'one point five megabits' */ long?: boolean; } @@ -204,6 +209,9 @@ 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; diff --git a/index.js b/index.js index 3e42920..511fadd 100644 --- a/index.js +++ b/index.js @@ -26,6 +26,7 @@ import { fancy, FANCY_STYLE_NAMES, egyptian, babylonian, greek, clock } from './ // 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 @@ -116,7 +117,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', 'constant', '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) => @@ -126,6 +138,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; } @@ -209,7 +237,7 @@ 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') @@ -873,26 +901,22 @@ const hex = (n, opt) => radix(n, 16, opt); // BYTES // ============================================================================ -const BYTE_UNITS = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']); -const BYTE_UNITS_BINARY = Object.freeze(['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']); -const BYTE_WORDS = Object.freeze(['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte', 'zettabyte', 'yottabyte']); -const BYTE_WORDS_BINARY = Object.freeze(['byte', 'kibibyte', 'mebibyte', 'gibibyte', 'tebibyte', 'pebibyte', 'exbibyte', 'zebibyte', 'yobibyte']); +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'] +}); -/** - * 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) => { +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() */ +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); @@ -901,8 +925,8 @@ const bytes = (n, opt) => { if (value < 0n) return false; const step = opt?.binary ? 1024n : 1000n; - const units = opt?.binary ? BYTE_UNITS_BINARY : BYTE_UNITS; - const words = opt?.binary ? BYTE_WORDS_BINARY : BYTE_WORDS; + const units = opt?.binary ? table.binary : table.decimal; + const words = opt?.binary ? table.binaryWords : table.decimalWords; const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); let unit = 0; @@ -926,6 +950,35 @@ const bytes = (n, opt) => { return `${amount} ${units[unit]}`; }; +/** + * 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 // ============================================================================ @@ -1208,5 +1261,6 @@ export { octal, hex, bytes, + bits, toWords }; diff --git a/site/app.js b/site/app.js index 8c61c47..503397f 100644 --- a/site/app.js +++ b/site/app.js @@ -1,7 +1,7 @@ import numberstring, { comma, ordinal, roman, year, currency, telephone, fraction, nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse, - scientific, binary, octal, hex, bytes, clock + scientific, binary, octal, hex, bytes, bits, clock } from './lib/index.js'; const LANGS = [ @@ -103,14 +103,23 @@ const render = (raw) => { 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)} · ${bytes(value, { binary: true })}` : false); + row('bytes', wholeInt ? bytes(value) : false); + row('bytes (binary)', wholeInt ? bytes(value, { binary: true }) : 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')); diff --git a/test/extras.test.js b/test/extras.test.js index fae8735..2d34187 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -1,7 +1,7 @@ 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, clock, + scientific, radix, binary, octal, hex, bytes, bits, clock, CAP_STYLES, roman, decimal, currency, egyptian, babylonian, greek, chinese, japanese, toWords } from '../index.js'; @@ -458,6 +458,28 @@ describe('bytes', () => { }); }); +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('🕐'); @@ -486,3 +508,39 @@ describe('clock', () => { 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('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(roman(4, { lower: true })).toBe('iv'); + }); +}); From 46abf5dad710cbab5438bdd51a21d8e27d4134ec Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:07:36 -0400 Subject: [PATCH 15/24] docs: full sweep for 1.2.0 (README at-a-glance table, AGENTS, CLAUDE, CONTRIBUTING, package metadata, site meta, OG image) --- AGENTS.md | 25 ++++++++++++++++++------ CLAUDE.md | 20 +++++++++---------- CONTRIBUTING.md | 26 ++++++++++++++++-------- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++--- package.json | 13 +++++++++++- site/index.html | 4 ++-- site/og.png | Bin 65525 -> 66431 bytes site/og.svg | 2 +- 8 files changed, 109 insertions(+), 32 deletions(-) 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/CLAUDE.md b/CLAUDE.md index 68abdc7..9e23735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,17 +69,15 @@ 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` -- `cap` casing styles (title, upper, lower, sentence, camel, pascal, snake, kebab, constant, dot); British `and` option, `nth()` suffixes, `compact()` (1.5K), `fancy()` Unicode styles, `nato()`/`icao`/`military` radio numerals, `morse()`, `scientific()`, `binary()`/`octal()`/`hex()`/`radix()`, `bytes()`/`bits()`, `clock()` -- Egyptian, Babylonian, Greek numerals; Chinese/Japanese `formal` financial numerals +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 4f1d3e1..9507692 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **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** - 700+ tests with 90%+ coverage, including per-language spot checks +- **Well tested** - 730+ tests with 90%+ coverage, including per-language spot checks - **Modern ES modules** - Tree-shakeable, with bundled TypeScript declarations ## Installation @@ -51,6 +51,39 @@ 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])` @@ -418,14 +451,26 @@ 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 diff --git a/package.json b/package.json index 8af96f3..a0bf815 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "numberstring", "version": "1.2.0", - "description": "Number One Way to Makes Words from Numbers", + "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": { @@ -27,21 +27,32 @@ }, "keywords": [ "bigint", + "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", "roman", + "scientific-notation", + "snake-case", "string", "text", "word", diff --git a/site/index.html b/site/index.html index 8251eea..c51d1c0 100644 --- a/site/index.html +++ b/site/index.html @@ -4,14 +4,14 @@ numberstring - + - + diff --git a/site/og.png b/site/og.png index 8aa7f7addddc6d23ddddbf917acf6508db0d6874..7d0e309000803ab6a69711fa2bc44ab39b4b9016 100644 GIT binary patch delta 21042 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�!hZI2`95l{|Dv%mpC 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 R1P4bruA1AKHJW%Q{s+H@J^TOw delta 20129 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 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 From ec0fcf6743e45e6841fe49a9ad0c9ad31fc43e7e Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:07:48 -0400 Subject: [PATCH 16/24] chore: keep .netlify/ out of the npm package --- .npmignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 88a3a437f21051682c4e7e4a50d2627fa83d272d Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:08:52 -0400 Subject: [PATCH 17/24] fix: nato() skips round folding on zero-padded input; bytes()/bits() keep guard digits when rounding; sync lockfile to 1.2.0 --- index.js | 5 +++-- package-lock.json | 4 ++-- test/extras.test.js | 4 ++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 511fadd..1641f16 100644 --- a/index.js +++ b/index.js @@ -742,7 +742,7 @@ const nato = (n, opt) => { // Round hundreds / thousands: "fife hundred", "wun tousand", "too fife tousand" const roundMatch = !opt?.digits && !fracPart && intPart.match(/^(\d{1,2})(\d?)(00)$/); - if (roundMatch && /[1-9]/.test(intPart) && intPart.length >= 3 && intPart.length <= 5) { + 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'); @@ -935,7 +935,8 @@ const dataSize = (n, opt, table) => { scale *= step; unit++; } - let amount = unit === 0 ? Number(value) : Number((value * 10n ** 6n) / scale) / 1e6; + // Keep two guard digits beyond the 6-decimal maximum so toFixed() rounds correctly + let amount = unit === 0 ? Number(value) : Number((value * 10n ** 8n) / scale) / 1e8; amount = Number(amount.toFixed(digits)); if (amount >= Number(step) && unit < units.length - 1) { unit++; 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/test/extras.test.js b/test/extras.test.js index 2d34187..b223e3b 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -291,6 +291,8 @@ describe('nato', () => { 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'); }); @@ -452,6 +454,8 @@ describe('bytes', () => { 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(-1)).toBe(false); expect(bytes(1.5)).toBe(false); expect(bytes('abc')).toBe(false); From 33a9b7ab5c2054fece9ec3c4b114c313f208a425 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:12:45 -0400 Subject: [PATCH 18/24] site: single bytes row --- site/app.js | 1 - 1 file changed, 1 deletion(-) diff --git a/site/app.js b/site/app.js index 503397f..52af51c 100644 --- a/site/app.js +++ b/site/app.js @@ -104,7 +104,6 @@ const render = (raw) => { 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('bytes (binary)', wholeInt ? bytes(value, { binary: true }) : false); row('bits', wholeInt ? bits(value) : false); row('morse', morse(parsed.str), 'roman'); row('fraction', smallInt && value >= 2 ? fraction(1, value) : false); From 7a15e2855973832fd3946360d7cc0c1d050563a6 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:15:08 -0400 Subject: [PATCH 19/24] fix: case negative phrases once; integer rounding in bytes()/bits(); CAP_STYLES lists aliases --- index.js | 47 ++++++++++++++++++++++++--------------------- test/extras.test.js | 9 +++++++++ 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/index.js b/index.js index 1641f16..b6138c8 100644 --- a/index.js +++ b/index.js @@ -118,7 +118,7 @@ const ten = (n) => { }; /** Casing styles accepted by the `cap` option */ -const CAP_STYLES = Object.freeze(['title', 'upper', 'lower', 'sentence', 'camel', 'pascal', 'snake', 'kebab', 'constant', 'dot']); +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(); @@ -915,7 +915,7 @@ const BIT_UNITS = Object.freeze({ binaryWords: ['bit', 'kibibit', 'mebibit', 'gibibit', 'tebibit', 'pebibit', 'exbibit', 'zebibit', 'yobibit'] }); -/** Shared engine for bytes() and bits() */ +/** 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; @@ -928,6 +928,7 @@ const dataSize = (n, opt, table) => { const units = opt?.binary ? table.binary : table.decimal; const words = opt?.binary ? table.binaryWords : table.decimalWords; const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); + const precision = 10n ** BigInt(digits); let unit = 0; let scale = 1n; @@ -935,20 +936,25 @@ const dataSize = (n, opt, table) => { scale *= step; unit++; } - // Keep two guard digits beyond the 6-decimal maximum so toFixed() rounds correctly - let amount = unit === 0 ? Number(value) : Number((value * 10n ** 8n) / scale) / 1e8; - amount = Number(amount.toFixed(digits)); - if (amount >= Number(step) && unit < units.length - 1) { + // 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++; - amount = Number((amount / Number(step)).toFixed(digits)); + scale *= step; + fixed = (value * precision * 2n + scale) / (scale * 2n); } + const whole = (fixed / precision).toString(); + const frac = digits ? (fixed % precision).toString().padStart(digits, '0').replace(/0+$/, '') : ''; + const amountStr = frac ? `${whole}.${frac}` : whole; + if (opt?.long) { - const amountWords = Number.isInteger(amount) ? cardinal(amount) : decimal(amount); - const noun = amount === 1 ? words[unit] : `${words[unit]}s`; + 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 `${amount} ${units[unit]}`; + return `${amountStr} ${units[unit]}`; }; /** @@ -1015,23 +1021,20 @@ const morse = (n) => { 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; - } - - if (typeof n !== 'number' || isNaN(n)) return false; + let value; + if (typeof n === 'bigint') value = n; + else if (typeof n === 'number' && !isNaN(n)) value = n; + else return false; - if (n >= 0) return cardinal(n, opt); + if (value >= 0) return cardinal(value, opt); - const result = cardinal(Math.abs(n), 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; }; diff --git a/test/extras.test.js b/test/extras.test.js index b223e3b..07d8106 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -456,6 +456,10 @@ describe('bytes', () => { 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(-1)).toBe(false); expect(bytes(1.5)).toBe(false); expect(bytes('abc')).toBe(false); @@ -532,6 +536,9 @@ describe('cap casing styles', () => { 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'); @@ -545,6 +552,8 @@ describe('cap casing styles', () => { 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'); }); }); From 82aee4ccb6dc231bbc3b1b2450d0d16dca5c23a1 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:17:15 -0400 Subject: [PATCH 20/24] feat: fancy('clock') maps each digit to a clock face; playground clocks row --- CHANGELOG.md | 2 +- README.md | 3 ++- index.d.ts | 2 +- numerals.js | 4 +++- site/app.js | 4 ++-- test/extras.test.js | 3 +++ 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c23096..d11257e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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`. +- `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`). - `bahasa` accepted as an alias for Indonesian. diff --git a/README.md b/README.md index 9507692..4874a54 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ 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`), `braille`. +Digits in a Unicode style: `circled` (default), `superscript`, `subscript`, `fullwidth`, `bold`, `doublestruck`, `sans`, `monospace`, `keycap` (alias `emoji`), `clock`, `braille`. ```javascript import { fancy } from 'numberstring'; @@ -219,6 +219,7 @@ fancy(42); // '④②' fancy(42, 'superscript'); // '⁴²' fancy(42, 'doublestruck'); // '𝟜𝟚' fancy(42, 'keycap'); // '4️⃣2️⃣' +fancy(814, 'clock'); // '🕗🕐🕓' fancy(-3.5, 'braille'); // '⠼⠤⠉⠨⠑' ``` diff --git a/index.d.ts b/index.d.ts index 632eff1..a32758a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -65,7 +65,7 @@ export interface CompactOptions { /** Unicode digit styles accepted by fancy() */ export type FancyStyle = | 'circled' | 'superscript' | 'subscript' | 'fullwidth' | 'bold' - | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'emoji' | 'braille'; + | 'doublestruck' | 'sans' | 'monospace' | 'keycap' | 'emoji' | 'clock' | 'braille'; export interface ScientificOptions extends Pick { /** Maximum significant digits, rounds half up (default 12) */ diff --git a/numerals.js b/numerals.js index 29aa52b..a09a226 100644 --- a/numerals.js +++ b/numerals.js @@ -50,6 +50,8 @@ const FANCY_STYLES = Object.freeze({ 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: '⠼' } }); @@ -61,7 +63,7 @@ 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), braille + * fullwidth, bold, doublestruck, sans, monospace, keycap (alias emoji), clock, braille * @returns {string|false} Styled digits or false if invalid * * @example diff --git a/site/app.js b/site/app.js index 52af51c..9113084 100644 --- a/site/app.js +++ b/site/app.js @@ -1,7 +1,7 @@ import numberstring, { comma, ordinal, roman, year, currency, telephone, fraction, nth, compact, fancy, egyptian, babylonian, greek, chinese, japanese, nato, morse, - scientific, binary, octal, hex, bytes, bits, clock + scientific, binary, octal, hex, bytes, bits } from './lib/index.js'; const LANGS = [ @@ -124,7 +124,7 @@ const render = (raw) => { row('fullwidth', fancy(parsed.str, 'fullwidth')); row('doublestruck', fancy(parsed.str, 'doublestruck')); row('emoji', fancy(parsed.str, 'emoji')); - row('clock', smallInt && value >= 0 && value <= 24 ? clock(value) : false, 'glyphs'); + 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'); diff --git a/test/extras.test.js b/test/extras.test.js index 07d8106..c85d7e3 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -159,6 +159,7 @@ describe('fancy', () => { monospace: '𝟺𝟸', keycap: '4️⃣2️⃣', emoji: '4️⃣2️⃣', + clock: '🕓🕑', braille: '⠼⠙⠃' }; expect([...FANCY_STYLE_NAMES].sort()).toEqual(Object.keys(expected).sort()); @@ -172,6 +173,8 @@ describe('fancy', () => { 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', () => { From f6ec7da7c6b137c2e5bfcbc49ca531ed2e71168d Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:18:29 -0400 Subject: [PATCH 21/24] feat: year() reads years beyond 9999 as cardinals, accepts BigInt --- CHANGELOG.md | 1 + README.md | 7 ++++--- index.d.ts | 4 ++-- index.js | 9 ++++++++- site/app.js | 2 +- test/extras.test.js | 18 +++++++++++++++++- test/index.test.js | 2 +- 7 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11257e..de500dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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`). +- `year()` accepts years beyond 9999 (read as cardinals) and BigInt. - `bahasa` accepted as an alias for Indonesian. ### Fixed diff --git a/README.md b/README.md index 4874a54..e88ae48 100644 --- a/README.md +++ b/README.md @@ -278,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])` diff --git a/index.d.ts b/index.d.ts index a32758a..62aae43 100644 --- a/index.d.ts +++ b/index.d.ts @@ -168,8 +168,8 @@ 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' */ diff --git a/index.js b/index.js index b6138c8..8dd201a 100644 --- a/index.js +++ b/index.js @@ -1078,8 +1078,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; diff --git a/site/app.js b/site/app.js index 9113084..f2f69ac 100644 --- a/site/app.js +++ b/site/app.js @@ -95,7 +95,7 @@ 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 <= 3999999999 ? roman(value) : false, 'roman'); - row('year', smallInt && value >= 1 && value <= 9999 ? year(value) : false); + 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, { oh: true }) : false); row('pilot', nato(parsed.str)); diff --git a/test/extras.test.js b/test/extras.test.js index c85d7e3..1aef1e2 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -1,7 +1,7 @@ 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, + scientific, radix, binary, octal, hex, bytes, bits, clock, CAP_STYLES, roman, decimal, currency, year, egyptian, babylonian, greek, chinese, japanese, toWords } from '../index.js'; @@ -560,3 +560,19 @@ describe('cap casing styles', () => { 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/index.test.js b/test/index.test.js index 315780b..e1bb6e8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1002,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); }); }); From 68d2466cb7cdc68695fc387cb37e72ba296e6fa7 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:21:12 -0400 Subject: [PATCH 22/24] fix: comma()/group()/lang never throw; comma keeps decimals; add fuzz suite --- CHANGELOG.md | 2 ++ README.md | 5 +++-- index.js | 24 ++++++++++++++++++----- test/fuzz.test.js | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 test/fuzz.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index de500dc..5de0d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. diff --git a/README.md b/README.md index e88ae48..a150dcb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Transform any number into beautiful words. From `42` to `"forty-two"`, from `100 - **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** - 730+ 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 @@ -392,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 diff --git a/index.js b/index.js index 8dd201a..c179aa7 100644 --- a/index.js +++ b/index.js @@ -97,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)); @@ -169,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; }; // ============================================================================ @@ -256,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') { @@ -1165,7 +1179,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; 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(); + } + } + }); +}); From a49d4c1a2ed54690772b180f19ab65bbe265e55f Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:29:26 -0400 Subject: [PATCH 23/24] =?UTF-8?q?fix:=20clamp=20digits=20options=20to=20fi?= =?UTF-8?q?nite=20integers;=20formal=20Japanese=20zero=20is=20=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 7 ++++--- languages/ja.js | 2 +- test/extras.test.js | 7 +++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index c179aa7..6536f5b 100644 --- a/index.js +++ b/index.js @@ -688,7 +688,7 @@ const compact = (n, opt) => { const [rawInt, fracPart = ''] = str.split('.'); // Leading zeros carry no magnitude: '0001000' is 1000 const intPart = rawInt.replace(/^0+(?=\d)/, ''); - const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); + 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'}`); @@ -835,7 +835,7 @@ const scientific = (n, opt) => { sig = all.slice(firstNonZero).replace(/0+$/, '') || '0'; } - const maxDigits = Math.max(1, Math.min(opt?.digits ?? 12, 36)); + 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(); @@ -941,7 +941,8 @@ const dataSize = (n, opt, table) => { const step = opt?.binary ? 1024n : 1000n; const units = opt?.binary ? table.binary : table.decimal; const words = opt?.binary ? table.binaryWords : table.decimalWords; - const digits = Math.max(0, Math.min(opt?.digits ?? 1, 6)); + // 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; diff --git a/languages/ja.js b/languages/ja.js index 9380023..3025421 100644 --- a/languages/ja.js +++ b/languages/ja.js @@ -94,7 +94,7 @@ const japanese = (n, opt) => { return false; } - if (num === 0n) return 'ゼロ'; + if (num === 0n) return formal ? '零' : 'ゼロ'; const str = num.toString(); const len = str.length; diff --git a/test/extras.test.js b/test/extras.test.js index 1aef1e2..b0860f0 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -270,6 +270,8 @@ describe('formal Chinese and Japanese numerals', () => { 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', () => { @@ -463,6 +465,11 @@ describe('bytes', () => { 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.536 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); From 29f3f0c1e4bd640ac9497fa867376b43a8edf1e3 Mon Sep 17 00:00:00 2001 From: Brian Funk Date: Tue, 6 Oct 2026 01:29:47 -0400 Subject: [PATCH 24/24] test: non-finite digits falls back to the default --- test/extras.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/extras.test.js b/test/extras.test.js index b0860f0..071d1d3 100644 --- a/test/extras.test.js +++ b/test/extras.test.js @@ -467,7 +467,7 @@ describe('bytes', () => { 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.536 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);