Convertir un número a palabras con Ruby

// Junio 19th, 2009 // programación

lobo_tuerto.png

Hace poco necesitaba escribir una cantidad usando palabras, así que me di una vuelta por internet para ver si algún alma caritativa había publicado alguna implementación en Ruby.

En este otro blog y también en este me encontré con una solución, pero revisando el código fuente se puede ver que tiene un problema:

12000000000.to_words
=> "doce mil "

Con mi programa:

numero_a_palabras(12000000000)
=> "doce mil millones"

Así que sin más, les comparto mi implementación. :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def numero_a_palabras(numero)
  de_tres_en_tres = numero.to_i.to_s.reverse.scan(/\d{1,3}/).map{|n| n.reverse.to_i}
 
  millones = [
    {true => nil, false => nil},
    {true => 'millón', false => 'millones'},
    {true => "billón", false => "billones"},
    {true => "trillón", false => "trillones"}
  ]
 
  centena_anterior = 0
  contador = -1
  palabras = de_tres_en_tres.map do |numeros|
    contador += 1
    if contador%2 == 0
      centena_anterior = numeros
      [centena_a_palabras(numeros), millones[contador/2][numeros==1]].compact if numeros > 0
    elsif centena_anterior == 0
      [centena_a_palabras(numeros), "mil", millones[contador/2][false]].compact if numeros > 0
    else
      [centena_a_palabras(numeros), "mil"] if numeros > 0
    end
  end
 
  palabras.compact.reverse.join(' ')
end
 
def centena_a_palabras(numero)
  especiales = {
    11 => 'once', 12 => 'doce', 13 => 'trece', 14 => 'catorce', 15 => 'quince',
    10 => 'diez', 20 => 'veinte', 100 => 'cien'
  }
  if especiales.has_key?(numero)
    return especiales[numero]
  end
 
  centenas = [nil, 'ciento', 'doscientos', 'trescientos', 'cuatrocientos', 'quinientos', 'seiscientos', 'setecientos', 'ochocientos', 'novecientos']
  decenas = [nil, 'dieci', 'veinti', 'treinta', 'cuarenta', 'cincuenta', 'sesenta', 'setenta', 'ochenta', 'noventa']
  unidades = [nil, 'un', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve']
 
  centena, decena, unidad = numero.to_s.rjust(3,'0').scan(/\d/).map{|i| i.to_i}
 
  palabras = []
  palabras << centenas[centena]
 
  if especiales.has_key?(decena*10 + unidad)
    palabras << especiales[decena*10 + unidad]
  else
    tmp = "#{decenas[decena]}#{' y ' if decena > 2 && unidad > 0}#{unidades[unidad]}"
    palabras << (tmp.blank? ? nil : tmp)
  end
 
  palabras.compact.join(' ')
end

Si encuentras algún error en el programa, anótalo en un comentario abajo.

Para los que quieran conocer un poco más acerca de la solución de éste problema, continuen leyendo.

Procedimiento

A grandes rasgos es:

  1. Dividir el número en grupos de tres
  2. Convertir cada uno de esos grupos a su cantidad en letras (cada grupo puede tener un valor entre 0 y 999)
  3. Después asignar los miles y millones

Pero primero, un poco de contexto.

Ruby y yo

Desde que conocí Ruby me llamó muchísimo la atención, yo provengo del linaje de C++ y cabe mencionar que me volví bastante adepto en él. Java nunca llamó mucho mi atención, creo por que en el fondo no me interesaba aprender una enorme cantidad de APIs. Si dominas C++ no tienes problema alguno con Java.

Pero Ruby fue diferente, al escribir código en Ruby me sentí holgado, muy holgado. Fue mi primer encuentro con un lenguaje con características de un lenguaje funcional. Hmm.

Ahí conocí los famosos blocks. Un block es básicamente un bloque de código que se pasa a un método. Sí, se que eso se podría lograr con apuntadores a funciones, entonces ¿qué tiene de diferente? Bueno, la diferencia es que ese bloque es evaluado desde el contexto donde fue definido y se crea algo llamado closure — esta es una de las partes finas de Ruby que es fundamental para sacarle el mayor provecho a este lenguaje.

En fin, ya sea por su tersa sintáxis o esa belleza que expresa con su minimalismo creo que Ruby es estupendo. Ahora me encuentro más desenvuelto en él.

A continuación veremos como se hace el clásico programa de convertir un número a palabras.

La aplicación que le he encontrado a este programa ha sido la de convertir una cantidad en una factura a su equivalente escrito con palabras.

Hay muchas formas de resolver este problema, yo les presento una.

1. Dividir el número en grupos de tres

Analizando el problema

Bueno, analizando un par de ejemplos de conversión de números a palabras nos podemos dar cuenta que todos los números los descomponemos en centenas. Es decir, en grupos de tres. Por ejemplo:
12345456 = 12, 345, 456

  1. doce millones
  2. trescientos cuarenta y cinco mil
  3. cuatrocientos cincuenta y seis

Entonces, ¿qué es lo primero que debemos hacer? Separar nuestro número en números de tres cifras.

Existen diferentes maneras de lograr esto, explicaré la habitual, como lo haría alguien que programara en C, después lo haré usando algunas funciones que Ruby me proporciona.

¿Cómo puedo obtener los dígitos de un número por separado?

Veamos un ejemplo sencillo, tomemos al 14.
Necesitamos obtener un 1 y un 4.
Este programita nos los da:

1
2
3
4
5
number = 14
while number > 0 do
  puts number - number/10 * 10
  number = number/10
end

Ahora analizemos la ejecución de nuestro programa.
La primera vez que entra al ciclo, lo que imprime puts es un 1, veamos la operación:

1
2
3
4
5
number - (number/10 * 10) #paréntesis solo para clarificar
14 - (14/10 * 10)
14 - (1 * 10) #al dividir dos enteros el resultado es un entero también, se pierde el residuo
14 - 10
"4"

Después number es reasignado con la operación:

1
2
3
number = number/10
number = 14/10
number = 1

La segunda vez que entra al ciclo sucede lo siguiente:

1
2
3
4
5
number - number/10 * 10
1 - 1/10 * 10
1 - 0 * 10
1 - 0
"1"

¿Quieren ver cómo funciona para un número de cuatro cifras?

1
number = 2376

Primera

1
2
3
4
5
6
7
8
9
number - number/10 * 10
2376 - 2376/10 * 10
2376 - 237 * 10
2376 - 2370
"6"
 
number = number/10
number = 2376/10
number = 237

Segunda

1
2
3
4
5
6
7
8
9
number - number/10 * 10
237 - 237/10 * 10
237 - 23 * 10
237 - 230
"7"
 
number = number/10
number = 237/10
number = 23

Tercera

1
2
3
4
5
6
7
8
9
number - number/10 * 10
23 - 23/10 * 10
23 - 2 * 10
23 - 20
"3"
 
number = number/10
number = 23/10
number = 2

Cuarta

1
2
3
4
5
number - number/10 * 10
2 - 2/10 * 10
2 - 0 * 10
2 - 0
"2"

Y bien, creo que podemos observar como sería para un número n ¿verdad?

Podemos hacerle algunos ajustes a nuestro programa para que nos separe los números de uno en uno, de dos en dos, de tres en tres o como los queramos. Introduzcamos un arreglo en donde guardar los grupos de números y un exponente.

1
2
3
4
5
6
7
8
9
10
cifras = []
e = 3
number = 145205
while number > 0 do
  cifras << number - number/10**e * 10**e
  number = number/10**e
end
puts cifras.inspect
 
=> [205, 145]

1
2
number = 28971109163
=> [163, 109, 971, 28]

Cabe mencionar que la operación 10**e es la que tiene la precedencia más alta, así, estás dos son equivalentes:

1
number - number/10**e * 10**e

1
number - number/(10**e) * (10**e)

El programa modificado nos da las cifras de tres en tres, justo lo que necesitamos. Es solo cuestión de ajustar e para obtener el número agrupado de manera distinta.

Ahora, ¿de qué otra manera lo puedo hacer en Ruby?
Qué tal, ¿usando cadenas y expresiones regulares?
Un primer acercamiento podría ser:

1
2
3
4
number = 1448
cifras = number.to_s.scan(/\d{1}/).map{|n| n.to_i}
puts cifras.inspect
=> [1, 4, 4, 8]

¡Funciona! pero, ¿cómo lo hace?
Primero convierte el número 1448 a una cadena que contiene los caracteres “1448″.
A continuación llama al método scan el cual busca coincidencias de un patrón dado por una expresión regular. Las coincidencias son depositadas en el arreglo que el método scan devuelve.

1
"1448".scan(/\d{1}/) => ["1", "4", "4", "8"]

Después llamamos al método map del arreglo, el cual nos mapea cada elemento del arreglo al elemento generado dentro de la operación del bloque de código. Por ejemplo el elemento 1 que es representado por i es mapeado a i.to_i, es decir se llama al método to_i (convertir a entero) directamente desde la cadena.

Para separar de dos en dos es muy similar, pero hay un pequeño problema, veamos:

1
2
3
4
number = 1448
cifras = number.to_s.scan(/\d{2}/).map{|n| n.to_i}
puts cifras.inspect
=> [14, 48]

La expresión regular /\d{1}/ nos indica que queremos encontrar todos los números que tengan una longitud de 1.
Por eso en una cadena como “1448″ el patrón se puede aplicar 4 veces.
Si la expresión fuese /\d{2}/ el resultado en nuestro programa sería [14, 48]

El problema aparece cuando tratamos de obtener las cifras para un número como los siguientes:

1
2
number = 293
=> [29]

1
2
number = 12345
=> [12, 34]

Como podemos observar se está comiendo un número en los números que no vienen exactamente en pares.
Y pasa lo mismo si comenzamos a agrupar de tres en tres con:

1
2
3
4
number = 12345
cifras = number.to_s.scan(/\d{3}/).map{|n| n.to_i}
puts cifras.inspect
=> [123]

1
2
number = 12345678
=> [123, 456]

Lo que sucede es que si la expresión regular no encuentra los números exactamente en grupos de 3, entonces no los toma en cuenta. Debemos informarle de que también nos interesan los números restantes, que podrían ser de tamaño 1 o 2.

1
2
3
4
number = 12345
cifras = number.to_s.scan(/\d{1,3}/).map{|n| n.to_i}
puts cifras.inspect
=> [123, 45]

Pero observen, las cifras que aparecen en ese arreglo se parecen a las que necesitamos ([12, 345]), pero no son esas.

1
[123, 45] != [12, 345]

Incluso si lo aplicamos con n = 12345678 obtenemos [123, 456, 78], y necesitamos obtener [12, 345, 678].

El problema es que scan está buscando coincidencias desde el principio de la cadena que representa al número. Necesitamos que empiece desde el otro lado.

1
2
3
4
number = 12345
cifras = number.to_s.reverse.scan(/\d{1,3}/).map{|n| n.to_i}
puts cifras.inspect
=> [543, 21]

Casi estamos ahí, pareciera que solo necesitáramos voltear esos números ¿verdad?

1
2
3
4
number = 12345678
cifras = number.to_s.reverse.scan(/\d{1,3}/).map{|n| n.reverse.to_i}
puts cifras.inspect
=> [345, 12]

1
2
number = 12345678
=> [678, 345, 12]

Listo, hemos logrado separar nuestro número en grupos de tres.

2. Convertir cada uno de esos grupos a su cantidad en letras

Ahora convertiremos esos numeros a su correspondiente en palabras con el siguiente método. En esta ocasión no lo explicaré de manera tan detallada. Se supone ya tienen algo de práctica ¿no? ;)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def centena_a_palabras(numero)
  especiales = {
    11 => 'once', 12 => 'doce', 13 => 'trece', 14 => 'catorce', 15 => 'quince',
    10 => 'diez', 20 => 'veinte', 100 => 'cien'
  }
  if especiales.has_key?(numero)
    return especiales[numero]
  end
 
  centenas = [nil, 'ciento', 'doscientos', 'trescientos', 'cuatrocientos', 'quinientos', 'seiscientos', 'setecientos', 'ochocientos', 'novecientos']
  decenas = [nil, 'dieci', 'veinti', 'treinta', 'cuarenta', 'cincuenta', 'sesenta', 'setenta', 'ochenta', 'noventa']
  unidades = [nil, 'un', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve']
 
  centena, decena, unidad = numero.to_s.rjust(3,'0').scan(/\d/).map{|i| i.to_i}
 
  palabras = []
  palabras << centenas[centena]
 
  if especiales.has_key?(decena*10 + unidad)
    palabras << especiales[decena*10 + unidad]
  else
    tmp = "#{decenas[decena]}#{' y ' if decena > 2 && unidad > 0}#{unidades[unidad]}"
    palabras << (tmp.blank? ? nil : tmp)
  end
 
  palabras.compact.join(' ')
end

Lo primero que hacemos es definir un Hash con algunos números que llamo especiales, esto es porque no siguen las reglas de nomenclatura de todos los demás números, como por ejemplo llamar dieciuno (o diez y uno) al once. :)

Busco el número que necesito en los especiales, si es así lo regreso inmediatamente.

Separo el número en tres partes para obtener la centena, la decena y la unidad.

A continuación agrego el nombre correspondiente a la centena a la respuesta. Checo si los dos números restantes (decena y unidad) se encuentran en el Hash de números especiales.
Si es así lo agregamos a la respuesta (representada por el arreglo llamado palabras).
Si no, entonces armó la respuesta con los datos provistos por decena y unidad.

Algunas salidas son:

1
2
centena_a_palabras(873)
=> "ochocientos setenta y tres"

1
2
centena_a_palabras(253)
=> "doscientos cincuenta y tres"

1
2
centena_a_palabras(113)
=> "ciento trece"

1
2
centena_a_palabras(909)
=> "novecientos nueve"

Descomponer un número en sus centenas

El siguiente paso es entonces generar un método que nos convierta los grupos que tenemos a su correspondiente en palabras:

1
2
3
4
5
6
7
8
9
10
11
def numero_a_palabras(numero)
  # Separamos el número de tres en tres
  centenas = numero.to_s.reverse.scan(/\d{1,3}/).map{|n| n.reverse.to_i}
  # Transformamos cada elemento de centenas a un arreglo que contiene un
  # único elemento: la cantidad en palabras, sólo si el número es mayor a cero
  palabras = centenas.map do |centena|
    [centena_a_palabras(centena)] if centena > 0
  end
 
  palabras
end

1
2
numero = 1023885
=> [["ochocientos ochenta y cinco"], ["veintitres"], ["un"]]

1
2
3
numero = 127923
[923, 127]
=> [["novecientos veintitres"], ["ciento veintisiete"]]

3. Asignar los miles y millones

Una observación más sobre cómo convertimos nosotros los números a palabras es que las centenas tomamos una sí y una no, para formar miles:

Por ejemplo si tenemos 12,345,456 lo agrupariamos así: 12,(345mil),456
Y este otro 30,234,514,300 de esta otra: (30mil),234,(514mil),300

Haciendo ese ajusto a nuestro programa queda:

1
2
3
4
5
6
7
8
9
10
11
12
13
def numero_a_palabras(numero)
  # Separamos el número de tres en tres
  centenas = numero.to_s.reverse.scan(/\d{1,3}/).map{|n| n.reverse.to_i}
  # Transformamos cada elemento de centenas a un arreglo que contiene un
  # único elemento: la cantidad en palabras, sólo si el número es mayor a cero
  miles = [nil, "mil"]
  i = -1
  palabras = centenas.map do |centena|
    i += 1
    [centena_a_palabras(centena), miles[i%2]] if centena > 0
  end
  palabras
end

Y nos da resultados del tipo:

1
2
n  = 43367773
=> [["setecientos setenta y tres", nil], ["trescientos sesenta y siete", "mil"], ["cuarenta y tres", nil]]

¿Qué le falta? Exacto, los millones.

Los millones también los agrupamos de dos en dos, con cada mil.
23,454,678,565,343,679 = (23,454billones),(678,565millones),(343,679)

Armados con este conocimiento y modificando un poco la forma en la que insertamos los miles, producimos el siguiente método:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def numero_a_palabras(numero)
  de_tres_en_tres = numero.to_i.to_s.reverse.scan(/\d{1,3}/).map{|n| n.reverse.to_i}
 
  millones = [
    {true => nil, false => nil},
    {true => 'millón', false => 'millones'},
    {true => "billón", false => "billones"},
    {true => "trillón", false => "trillones"}
  ]
 
  centena_anterior = 0
  contador = -1
  palabras = de_tres_en_tres.map do |numeros|
    contador += 1
    if contador%2 == 0
      centena_anterior = numeros
      [centena_a_palabras(numeros), millones[contador/2][numeros==1]].compact if numeros > 0
    elsif centena_anterior == 0
      [centena_a_palabras(numeros), "mil", millones[contador/2][false]].compact if numeros > 0
    else
      [centena_a_palabras(numeros), "mil"] if numeros > 0
    end
  end
 
  palabras.compact.reverse.join(' ')
end

Para agregarle más rango al programa, solo adiciona más elementos al Hash llamado millones.

¡Listo, hasta la próxima!

Artículos relacionados:

4 Responses to “Convertir un número a palabras con Ruby”

  1. Gracias por tus comentarios en nuestro gem. Será corregido próximamente.

    Saludos,
    Iván

  2. Elgocho VENEZUELA Linux Mozilla Firefox 3.0.4pre dice:

    Amigo y como lo instalo?????

  3. lobo_tuerto MEXICO Ubuntu Linux Mozilla Firefox 3.0.12 dice:

    Ah, pues lo único que debes hacer es copiar el código y pegarlo en el programa donde lo vayas a utilizar.

    Pero ya voy a armar una gema y subirla a github, para una mejor distribución. :)

  4. Gustavo Ortiz MEXICO Windows XP Google Chrome 2.0.172.43 dice:

    Excelente observación con lo de los doce mil millones. Revisaré con Iván si ya está arreglado en alguna nueva versión, si no para hecharle una manita en una oportunidad.

Leave a Reply

FireStats icon Con la potencia de FireStats