dozenal

A module for converting base-10 numbers to base-12 strings.

Usage no npm install needed!

<script type="module">
  import dozenal from 'https://cdn.skypack.dev/dozenal';
</script>

README

dozenal

This is a module to print numbers in Base 12. An optional format string can be passed in to specify output width and/or case.

To be extra silly, you can also say the numbers, which pronounces them with words. I went ahead and made up the names for very large powers, so be aware "bo" does not line up with "a billion" and so forth. Test output is included at the bottom.

Example Usage

dozenal = require 'dozenal'
dozenal.print 300
dozenal.say 300
Input Printed Said
7 '7' 'seven'
-7 '-7' 'minus seven'
10 'T' 'dec'
10, 'd' 't' 'dec'
10, '4d' '___t' 'dec'
11 'E' 'el'
12 '10' 'doh'
24 '20' 'two-doh'
144 '100' 'gro'
25.5 '21.6' 'two-doh one point six'
3.14159265, '.3' '3.184' 'three point one eight four'

The Module

This module exports two functions: .print(..) and .say(..). Both take normal Numbers and both return Strings. They both take an optional format string X.Y that can limit the width of the integer part (X wide) and the fractional part (Y wide). An optional d in Xd.Y will output dec and el as t and e instead of the default T and E.

Print it

exports.print = (number, format='') ->
  sign = if -1 is Math.sign number then '-' else ''
  number = Math.abs number
  integer = Math.floor number
  fraction = number - integer

First the input is separated into a minus sign ('-'/''), a positive integer, and a positive fraction.

  [format, iWidth, upperLower, fWidth] = /(\d?)([dD]?).?(\d?)/.exec format
  [iFixed, fFixed] = [iWidth isnt '', fWidth isnt '']
  iWidth = Number(iWidth || 0)
  fWidth = Number(fWidth || 0)

A regular expression captures the 3 parts of the format string: the integer width, the upper/lower case, and the fractional width. If these are not passed in, the entire number should be shown (but no more). The _Fixed flags indicate whether the _Width fields mean anything, as their default value 0 could be a real width passed in as 0 or 0.0, etc.

  table = numerals[upperLower || 'D']
  digits = integerToDigits integer, table, iFixed, iWidth
  fractions = fractionToDigits fraction, table, fFixed, fWidth

A table lookup finds the correct set of numerals (..8 9 t e vs ..8 9 T E). The digits & fractional digits are both converted to an array of numerals.

  if fractions.length is 0 then "#{sign}#{digits.join ''}"
  else "#{sign}#{digits.join ''}.#{fractions.join ''}"

If the input was an integer, the dozenal point fractional part are omitted. In either case, the sign and digits are output. There are just 2 helper functions, integerToDigits and fractionToDigits.

integerToDigits = (integer, numerals, fixed, width) ->
  output = []
  if integer == 0 and (!fixed or width > 0)
    width--
    output.unshift '0'

Zero is a special case since it technically is the only leading zero that is output. It is only output if the width allows for it (format of '0.' omits it).

  while integer >= 1 and (!fixed or width > 0)
    width--
    lsb = integer % 12
    output.unshift numerals[lsb]
    integer -= lsb
    integer /= 12

The least significant digits are prepended to the output first. The integer is shifted right every step (in base 12). This continues until the integer is 0 or the fixed width is reached.

  while fixed and width > 0
    output.unshift ' '
    width--
  output

If the output is fixed and the width has not yet been reached, spaces are prepended to the output.

fractionToDigits = (fraction, numerals, fixed, width) ->
  output = []
  while fraction > 0 and (!fixed or width > 0)
    width--
    fraction *= 12
    msb = Math.floor fraction
    output.push numerals[msb]
    fraction -= msb

The most significant digits are appended to the output first. The fraction is shifted left every step (in base 12). Each digit is obtained not through modulus but by shifting a digit left of the dozenal point then truncating the digits right of the dozenal point. This continues until the fractional part drops to 0 (no trailing zeroes are output).

  while fixed and width > 0
    output.push ' '
    width--
  output

If the output is fixed and the width has not been reached, spaces are appended.

Say it

exports.say = (number, format='') ->
  output = []
  minus = if Math.sign(number) is -1 then 'minus ' else ''
  number = Math.abs number

  dozenal = exports.print number, format
  [digits, fractions] = dozenal.split '.'
  digits = if digits then digits.split('').reverse() else []
  fractions = if fractions then fractions.split('') else []

First, the number is printed into dozenal form to the specified width. That printed form is separated into a minus sign, an array of integer digits, and an array of fractional digits. The digits are reversed so that the least significant comes first. This makes it easier to say the proper power without knowing how many digits come later.

  output = sayDigits digits, output
  output = sayFractions fractions, output
  "#{minus}#{output.join ' '}"

The arrays numerals are converted to arrays of words with helper functions sayDigits and sayFractions. The minus sign is printed, then the words are joined with spaces.

sayFractions = (fractions, output=[]) ->
  if fractions?.length > 0
    output.push 'point'
    while fractions.length > 0
      output.push words[fractions.shift()]
  output

sayFractions is the simplest of the helper functions. Fractional numerals are not said with powers, so the numerals are converted into words one by one. The parameter output holds the entire number so far, so words are pushed onto it.

sayDigits = (digits, output=[]) ->
  switch digits.length
    when 1
      if digits[0] is '0' then output.push 'zero'
      else output.push words[digits[0]]
      output

When there is just a single numeral in the digits, it can be handled directly. Zero is a special case as it is a leading zero, and otherwise just the numeral (without the power) is output.

    else
      sayDigitsReversed digits, 0, output
      .filter (str) -> str isnt ''
      .reverse()

When there are at least 2 digits, another helper function sayDigitsReversed outputs the numerals in groups of 3 according to their power (like "one hundred one million, seventy two thousand"). This helper returns an array of strings, only some of which are not empty. The empty strings are filtered out then the words are reversed to put the most significant digit back in front.

sayDigitsReversed = (digits, power=0, output=[]) ->
  if digits.length is 0
    return output

  group = digits.splice(0, 3)
  if group.every((d) -> d == '0')
    return sayDigitsReversed digits, power + 3, output

This function works recursively, pulling off the least significant digits (as it was reversed before passed as argument) in groups of 3. If every digit in the group is zero, nothing is output. The only special case where zero needed to be printed was done in sayDigits. The recursion would then continue with the remaining digits on the next higher power (power + 3).

  output.push powers[power]

If there is at least one nonzero digit in this group, the power is output. Note that for the lowest group, the power is '' so nothing is printed. These empty strings are filtered out above in sayDigits.

For each of the three digits in the group, either an empty string or a word is pushed onto the output.

  output.push switch group[0]
    when undefined then ''
    when '0' then ''
    else
      if group[0] is '1' and power > 0 and !group[1] and !group[2] then ''
      else words[group[0]]

If the lowest digit is the leading digit (group[1] and group[2] are undefined), then the "one" is omitted and just the power is output (like a "million", not "one million").

  output.push switch group[1]
    when undefined then ''
    when '0' then ''
    when '1' then "#{powers[power + 1]}"
    else "#{words[group[1]]}-#{powers[power + 1]}"
  output.push switch group[2]
    when undefined then ''
    when '0' then ''
    when '1' then "#{powers[power + 2]}"
    else "#{words[group[2]]}-#{powers[power + 2]}"

  sayDigitsReversed digits, power + 3, output

The second and third digits in the group are output with their corresponding power, hyphenated to show they are part of the group. Finally, the remainder of the digits are said through a recursive call.

Constants: strings to express numerals and powers

numerals =
  'D': [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'E', ],
  'd': [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 't', 'e', ],

words =
  '1': 'one',
  '2': 'two',
  '3': 'three',
  '4': 'four',
  '5': 'five',
  '6': 'six',
  '7': 'seven',
  '8': 'eight',
  '9': 'nine',
  'T': 'dec',
  't': 'dec',
  'E': 'el',
  'e': 'el',

powers =
  0: '',
  1: 'doh',
  2: 'gro',
  3: 'mo',
  4: 'doh',
  5: 'gro',
  6: 'bo',
  7: 'doh',
  8: 'gro',
  9: 'tro',
  10: 'doh',
  11: 'gro',
  12: 'quadro',
  13: 'doh',
  14: 'gro',
  15: 'quindo',
  16: 'doh',
  17: 'gro',
  18: 'sexdo',
  19: 'doh',
  20: 'gro',
  21: 'sepdo',
  22: 'doh',
  23: 'gro',
  24: 'ocdo',
  25: 'doh',
  26: 'gro',
  27: 'nondo',

Test Output

npm run test

dozenal exports 2 functions
  .print(number) returns a string in base-12
    ✓ print 0 is "0"
    ✓ print 10 is "T"
    ✓ print 11 is "E"
    ✓ print 12 is "10"
    ✓ print -12 is "-10"
    ✓ print 24.0625 is "20.09"
    ✓ print 25.5 is "21.6"
    ✓ print 250561 is "101001"
  .print(number, "d") returns a lowercase equivalent
    ✓ print 10, 'd' is "t"
    ✓ print 11, 'e' is "e"
  .print(number, "5d") prints exactly 5 digits
    ✓ print 10, '5d' is "    t"
    ✓ print 1000000, '5d' is "02854"
  .print(number, "1.5") prints 1 digit and 5 dozenal places
    ✓ print pi, '1.5' is "3.18480"
    ✓ print 1.5, '1.5' is "1.6    "
    ✓ print 0.25, '0.' is ".3"
  .say(number) returns a string of pronouncable words
    ✓ 0 is "zero"
    ✓ 10 is "dec"
    ✓ -10 is "minus dec"
    ✓ 11 is "el"
    ✓ 12 is "doh"
    ✓ 144 is "gro"
    ✓ 157 is "gro doh one"
    ✓ 25.5 is "two-doh one point six"
    ✓ 250,561 is "gro one mo one"
  .say(..) can pronounce fractions
    ✓ pi to 4 dozenal places is "three point one eight four eight"
    ✓ 0.25 is "zero point three"
    ✓ 0.25, '0.' is "point three"
  .say(..) works up to nondo (12^27)
    ✓ 12^0 is "one"
    ✓ 12^1 is "doh"
    ✓ 12^2 is "gro"
    ✓ 12^3 is "mo"
    ✓ 12^6 is "bo"
    ✓ 12^9 is "tro"
    ✓ 12^12 is "quadro"
    ✓ 12^15 is "quinto"
    ✓ 12^18 is "sexto"
    ✓ 12^21 is "septo"
    ✓ 12^24 is "octo"
    ✓ 12^27 is "nondo"
    ✓ -12^27 is "minus nondo"
    ✓ the low digits of MAX_SAFE_INTEGER are "dec-gro two-doh seven"


41 passing (41ms)

This file is written in Literate CoffeeScript: all source code is shown above. The module index.js is generated from this file.