Numalet: convertir números a letras en C# y VB

29 11 2007

Indefectiblemente en algún momento necesitamos o vamos a necesitar código para transformar un número a su numeral cardinal, o sea: 44 en “Cuarenta y cuatro”. Este es otro caso en donde buscamos en google “numeros a letras” o algo así esperando una solución hecha. Bueno, aca la mía: Bajar Numalet, Esta es una solución de VS 2005 con una clase en C#, en VB, un form para probar la clase y un sencillo test unitario para comprobar que las clases en ambos lenguajes funcionan igual.

Al final del post está el código de la clase en C# y en VB para copiarlo directamente.

¿por que usar esta clase y no otra? realmente no hay ningún motivo en especial, cualquier código que resuelva este trivial tema esta bien. La clase que presento genera números acorde a la especificación de la RAE, respetando acentos, apócopes, etc.

Por otro lado, todos los algoritmos que vi generan la salida según el uso del país de quien lo programa, y si queremos otra salida debemos copiar el algoritmo o perder la configuración anterior, asique agregué algunas propiedades que permiten configurar la salida con bastante libertad para cada instancia de la clase.

Hechas estas aclaraciones, veamos un ejemplo sencillo:

C#: Numalet.ToCardinal(8,25);

VB: Numalet.ToCardinal(8,25)

retorna:

ocho con 25/100.-

No hace falta instanciar la clase ya que el método ToCardinal es estático, ahora, si queremos una salida distinta (ahora si instanciando la clase), podemos jugar con las siguientes propiedades:

  • CultureInfo: Permite especificar que configuración regional se utiliza para parsear los números en la sobrecarga que toma strings, en nuestro caso nos importa qué separador decimal (punto o coma) reconocerá, por defecto está seteado en la configuración del thread en que corre el proceso, para cambiarlo de manera que reconozca el punto, por caso, el valor de la propiedad podría ser new CultureInfo(“en-US”). El método estático ToCardinal tiene opcionalmente una sobrecarga que acepta un CultureInfo, por lo que en una máquina donde el separador decimal es la coma, podemos traducir un número que tiene punto como separador decimal de la siguiente manera:

Numalet.ToCardinal(“15.2”,CultureInfo(”en-US”));

Lo que retorna: “quince con 20/100.-“.

  • SeparadorDecimalSalida: Es el texto que aparece entre la parte entera y la decimal, por defecto “con”, por ejemplo: 2,28 = Dos con 28/100.-
  • LetraCapital: Por defecto false, permite obtener “quince” o “Quince“.
  • ConvertirDecimales: Por defecto false, permite especificar si los decimales se muestran como número o como texto.
  • MascaraSalidaDecimal: Es la forma en la que se muestran los decimales, utiliza una cadena para dar formato, por defecto es “00/100.-”.

El comportamiento varía según se estén convirtiendo o no los decimales. Para ello he implementado la siguiente lógica: Si el valor de MascaraSalidaDecimal comienza con ‘#’ o ‘0’, se interpreta como una máscara ‘numérica’ y se utiliza como máscara para los decimales, por ejemplo el caso por defecto (00/100.-).

Si en cambio la cadena empieza por cualquier otro caracter, se toma como literal y se agrega al final del resultado. Por ejemplo: MascaraSalidaDecimal = “centavos” => 45,28 = cuarenta y cinco con 28 centavos.

Hay que notar que todos los caracteres posteriores a los ‘0’ o ‘#’ se toman como literales, internamente se entrecomillan con (‘ ) comilla simple, por lo tanto, si queremos que aparezca una comilla simple en la máscara, debemos escribirla doble, por ej. si MascaraSalidaDecimal = litros de Jack Daniel”s, (y SeparadorDecimalSalida = ‘coma’ ) entonces 1,2 = uno coma dos litros de Jack Daniel’s.

  • Decimales: Indica la posición decimal que se utilizará para redondear los decimales al convertirlos a enteros. Recordemos que al pasar un número a su nombre, los decimales se expresan como un entero (Ej. 0,21 = ‘cero con 21 centésimas’, el 21 es entero), pero en un número como 8,54613654 ¿cual será el entero que represente los decimales?. Bueno, es lo que se especifica con esta propiedad, por ejemplo si Decimales=2 (valor por defecto) entonces 8,54613654 = ‘ocho con 55/100.-‘

Esta propiedad cambia automáticamente al variar MascaraSalidaDecimal. La cantidad de caracteres ‘0’ o ‘#’ hasta el primer caracter distinto se toma como valor para Decimales. O sea, si MascaraSalidaDecimal = “##0 sobre mil”, entonces Decimales = 3, por lo que 456,45667 se convierte en ‘cuatrocientos cincuenta y seis con 457 sobre mil’

Tener en cuenta que el funcionamiento de MascaraSalidaDecimal es un poco diferente que las cadenas de formato del framework, por ejemplo, es lo mismo en el framework hacer #/100 que ###/100, pero en nuestro caso sirve para establecer que queremos redondear en tres decimales.

Sea cual sea el valor de MascaraSalidaDecimal podemos cambiar la propiedad Decimales con posterioridad y a nuestro gusto.

  • ApocoparUnoParteDecimal: En castellano, cuando los números cuantifican un sustantivo masculino, se apocopa (se recorta el final de la palabra) de las decenas mayores a veinte terminadas en uno, ¿que? que en vez de poner “treinta y uno elefantes” ponemos “treinta y un elefantes”. Esta propiedad se puede setear individualmente pero también cambia junto con el valor de ConvertirDecimales ya que si queremos convertir decimales a texto, lo normal es apocopar la palabra uno, por ejemplo veintiún centavos. Tener en cuenta que se utiliza solo cuando ConvertirDecimales es verdadero.
  • ApocoparUnoParteEntera: Lo mismo que la anterior pero para las unidades de nuestro número. Esta propiedad cambia junto con SeparadorDecimalSalida. Si seteamos esta última a, por ejemplo, “pesos con”, entonces se deduce que estoy cualificando algo (pesos) y la segunda palabra (con) se toma como conjunción (adverbio conjuntivo para los puristas creo), por lo que ApocoparUnoParteEntera pasa a ser true. ¿No entendiste? somos dos, veamos algunos ejemplos:
    • si SeparadorDecimalSalida = ‘con’ entonces ApocoparUnoParteEntera es false y 31,2 = ‘treinta y uno con 20/100.-‘
    • si SeparadorDecimalSalida = ‘euros con’ entonces ApocoparUnoParteEntera es true y 31,2 = ‘treinta y un euros con 20/100.-‘

    Esta propiedad también se puede cambiar con posterioridad a SeparadorDecimalSalida si no se desea este comportamiento.

El código tiene en cuenta que:

  • Los números 16,21,22,23 y 26 se acentúan.
  • El 21 cuando se apocopa se acentúa (veintiún).
  • Cuando cuantificamos miles, cientos de miles o millones, hay que apocopar la palabra o terminación “uno”, por ejemplo 21 es “veintiuno”, pero 21.000 es “veintiún mil”.
  • Permite convertir números mayores o iguales a cero y menores que un billón. o sea, el mayor número permitido es 999.999.999.999 ¿te alcanza?

No tiene en cuenta el género femenino, por ejemplo no genera “quinientas mujeres”. Se puede tocar el código para tener en cuenta esto, pero hay que tener cuidado porque los millones son siempre masculinos: “quinientos millones quinientas mil mujeres”, y los apocopés no se utilizan en numéros femeninos, por ej. ‘treinta y una montañas’ y no ‘treinta y un montañas’. Si alguien lo utiliza lo agrego.

Rendimiento:

La clase convierte 1.000.000 de números generados al azar en menos de 7 segundos en mi máquina (P4). Casi no hay ventaja en instanciar la clase, conviene hacerlo solo si vamos a variar la configuración.

Algunos ejemplos:

MessageBox.Show(Numalet.ToCardinal("18,25"));
//dieciocho con 25/100.-

//Si tenemos el número en un string con otro separador decimal (por ejemplo punto):
MessageBox.Show(Numalet.ToCardinal("155.38", new CultureInfo("en-US")));
//ciento cincuenta y cinco con 38/100.-

//instanciando la clase podemos generar salidas variadas
Numalet let;
let = new Numalet();
//un porcentaje como he visto en algunos documentos de caracter legal:
let.MascaraSalidaDecimal = "por ciento";
let.SeparadorDecimalSalida = "con";
let.ConvertirDecimales = true;
MessageBox.Show(let.ToCustomCardinal(21.2));
//veintiuno con veinte por ciento

let = null;
let = new Numalet();
//al uso en México (creo):
let.MascaraSalidaDecimal = "00/100 M.N.";
let.SeparadorDecimalSalida = "pesos";
//observar que sin esta propiedad queda "veintiuno pesos" en vez de "veintiún pesos":
let.ApocoparUnoParteEntera = true;
MessageBox.Show("Son: " + let.ToCustomCardinal(1121.24));
//Son: un mil ciento veintiún pesos 24/100 M.N.

//algo más raro
let.MascaraSalidaDecimal = "###0 dracmas";
//###0 quiere hace que se redondee a 4 decimales y no se muestren los ceros a la izquierda,
//en cambio 0000 haría que, aparte de redondear en 4, se muestren los ceros a la izquierda.
let.SeparadorDecimalSalida = "talentos y";
let.LetraCapital = true;
MessageBox.Show(let.ToCustomCardinal(12.085));
//Doce talentos y 850 dracmas

//una variación del anterior redondeando decimales a mano
let.ConvertirDecimales = true;
//redondeando en cuatro decimales
let.Decimales = 4;
let.MascaraSalidaDecimal = "dracmas";
MessageBox.Show(let.ToCustomCardinal(21.50028354));
//Veintiún talentos y cinco mil tres dracmas
let = null;

Para quien quiera un comportamiendo fijo distinto al que tiene la clase, puede cambiar los valores de las constantes en la clase.

Espero que les sirva y saludos.

Clase en C#:

using System;
using System.Text;
using System.Globalization;

/// <summary>
/// Convierte números en su expresión numérica a su numeral cardinal
/// </summary>
public sealed class Numalet
{
    #region Miembros estáticos

    private const int UNI = 0, DIECI = 1, DECENA = 2, CENTENA = 3;
    private static string[,] _matriz = new string[CENTENA + 1, 10]
        {
            {null," uno", " dos", " tres", " cuatro", " cinco", " seis", " siete", " ocho", " nueve"},
            {" diez"," once"," doce"," trece"," catorce"," quince"," dieciséis"," diecisiete"," dieciocho"," diecinueve"},
            {null,null,null," treinta"," cuarenta"," cincuenta"," sesenta"," setenta"," ochenta"," noventa"},
            {null,null,null,null,null," quinientos",null," setecientos",null," novecientos"}
        };

    private const Char sub = (Char)26;
    //Cambiar acá si se quiere otro comportamiento en los métodos de clase
    public const String SeparadorDecimalSalidaDefault = "con";
    public const String MascaraSalidaDecimalDefault = "00'/100.-'";
    public const Int32 DecimalesDefault = 2;
    public const Boolean LetraCapitalDefault = false;
    public const Boolean ConvertirDecimalesDefault = false;
    public const Boolean ApocoparUnoParteEnteraDefault = false;
    public const Boolean ApocoparUnoParteDecimalDefault = false;

    #endregion

    #region Propiedades

    private Int32 _decimales = DecimalesDefault;
    private CultureInfo _cultureInfo = CultureInfo.CurrentCulture;
    private String _separadorDecimalSalida = SeparadorDecimalSalidaDefault;
    private Int32 _posiciones = DecimalesDefault;
    private String _mascaraSalidaDecimal, _mascaraSalidaDecimalInterna = MascaraSalidaDecimalDefault;
    private Boolean _esMascaraNumerica = true;
    private Boolean _letraCapital = LetraCapitalDefault;
    private Boolean _convertirDecimales = ConvertirDecimalesDefault;
    private Boolean _apocoparUnoParteEntera = false;
    private Boolean _apocoparUnoParteDecimal;

    /// <summary>
    /// Indica la cantidad de decimales que se pasarán a entero para la conversión
    /// </summary>
    /// <remarks>Esta propiedad cambia al cambiar MascaraDecimal por un valor que empieze con '0'</remarks>
    public Int32 Decimales
    {
        get { return _decimales; }
        set
        {
            if (value > 10) throw new ArgumentException(value.ToString() + " excede el número máximo de decimales admitidos, solo se admiten hasta 10.");
            _decimales = value;
        }
    }

    /// <summary>
    /// Objeto CultureInfo utilizado para convertir las cadenas de entrada en números
    /// </summary>
    public CultureInfo CultureInfo
    {
        get { return _cultureInfo; }
        set { _cultureInfo = value; }
    }

    /// <summary>
    /// Indica la cadena a intercalar entre la parte entera y la decimal del número
    /// </summary>
    public String SeparadorDecimalSalida
    {
        get { return _separadorDecimalSalida; }
        set
        {
            _separadorDecimalSalida = value;
            //Si el separador decimal es compuesto, infiero que estoy cuantificando algo,
            //por lo que apocopo el "uno" convirtiéndolo en "un"
            if (value.Trim().IndexOf(" ") > 0)
                _apocoparUnoParteEntera = true;
            else _apocoparUnoParteEntera = false;
        }
    }

    /// <summary>
    /// Indica el formato que se le dara a la parte decimal del número
    /// </summary>
    public String MascaraSalidaDecimal
    {
        get
        {
            if (!String.IsNullOrEmpty(_mascaraSalidaDecimal))
                return _mascaraSalidaDecimal;
            else return "";
        }
        set
        {
            //determino la cantidad de cifras a redondear a partir de la cantidad de '0' o '#' 
            //que haya al principio de la cadena, y también si es una máscara numérica
            int i = 0;
            while (i < value.Length
                && (value[i] == '0')
                    | value[i] == '#')
                i++;
            _posiciones = i;
            if (i > 0)
            {
                _decimales = i;
                _esMascaraNumerica = true;
            }
            else _esMascaraNumerica = false;
            _mascaraSalidaDecimal = value;
            if (_esMascaraNumerica)
                _mascaraSalidaDecimalInterna = value.Substring(0, _posiciones) + "'"
                    + value.Substring(_posiciones)
                    .Replace("''", sub.ToString())
                    .Replace("'", String.Empty)
                    .Replace(sub.ToString(), "'") + "'";
            else
                _mascaraSalidaDecimalInterna = value
                    .Replace("''", sub.ToString())
                    .Replace("'", String.Empty)
                    .Replace(sub.ToString(), "'");
        }
    }

    /// <summary>
    /// Indica si la primera letra del resultado debe estár en mayúscula
    /// </summary>
    public Boolean LetraCapital
    {
        get { return _letraCapital; }
        set { _letraCapital = value; }
    }

    /// <summary>
    /// Indica si se deben convertir los decimales a su expresión nominal
    /// </summary>
    public Boolean ConvertirDecimales
    {
        get { return _convertirDecimales; }
        set
        {
            _convertirDecimales = value;
            _apocoparUnoParteDecimal = value;
            if (value)
            {// Si la máscara es la default, la borro
                if (_mascaraSalidaDecimal == MascaraSalidaDecimalDefault)
                    MascaraSalidaDecimal = "";
            }
            else if (String.IsNullOrEmpty(_mascaraSalidaDecimal))
                //Si no hay máscara dejo la default
                MascaraSalidaDecimal = MascaraSalidaDecimalDefault;
        }
    }

    /// <summary>
    /// Indica si de debe cambiar "uno" por "un" en las unidades.
    /// </summary>
    public Boolean ApocoparUnoParteEntera
    {
        get { return _apocoparUnoParteEntera; }
        set { _apocoparUnoParteEntera = value; }
    }

    /// <summary>
    /// Determina si se debe apococopar el "uno" en la parte decimal
    /// </summary>
    /// <remarks>El valor de esta propiedad cambia al setear ConvertirDecimales</remarks>
    public Boolean ApocoparUnoParteDecimal
    {
        get { return _apocoparUnoParteDecimal; }
        set { _apocoparUnoParteDecimal = value; }
    }

    #endregion

    #region Constructores

    public Numalet()
    {
        MascaraSalidaDecimal = MascaraSalidaDecimalDefault;
        SeparadorDecimalSalida = SeparadorDecimalSalidaDefault;
        LetraCapital = LetraCapitalDefault;
        ConvertirDecimales = _convertirDecimales;
    }

    public Numalet(Boolean ConvertirDecimales, String MascaraSalidaDecimal, String SeparadorDecimalSalida, Boolean LetraCapital)
    {
        if (!String.IsNullOrEmpty(MascaraSalidaDecimal))
            this.MascaraSalidaDecimal = MascaraSalidaDecimal;
        if (!String.IsNullOrEmpty(SeparadorDecimalSalida))
            _separadorDecimalSalida = SeparadorDecimalSalida;
        _letraCapital = LetraCapital;
        _convertirDecimales = ConvertirDecimales;
    }
    #endregion

    #region Conversores de instancia

    public String ToCustomCardinal(Double Numero)
    { return Convertir((Decimal)Numero, _decimales, _separadorDecimalSalida, _mascaraSalidaDecimalInterna, _esMascaraNumerica, _letraCapital, _convertirDecimales, _apocoparUnoParteEntera, _apocoparUnoParteDecimal); }

    public String ToCustomCardinal(String Numero)
    {
        Double dNumero;
        if (Double.TryParse(Numero, NumberStyles.Float, _cultureInfo, out dNumero))
            return ToCustomCardinal(dNumero);
        else throw new ArgumentException("'" + Numero + "' no es un número válido.");
    }

    public String ToCustomCardinal(Decimal Numero)
    { return ToCardinal((Numero)); }

    public String ToCustomCardinal(Int32 Numero)
    { return Convertir((Decimal)Numero, 0, _separadorDecimalSalida, _mascaraSalidaDecimalInterna, _esMascaraNumerica, _letraCapital, _convertirDecimales, _apocoparUnoParteEntera, false); }

    #endregion

    #region Conversores estáticos

    public static String ToCardinal(Int32 Numero)
    {
        return Convertir((Decimal)Numero, 0, null, null, true, LetraCapitalDefault, ConvertirDecimalesDefault, ApocoparUnoParteEnteraDefault, ApocoparUnoParteDecimalDefault);
    }

    public static String ToCardinal(Double Numero)
    {
        return ToCardinal((Decimal)Numero);
    }

    public static String ToCardinal(String Numero, CultureInfo ReferenciaCultural)
    {
        Double dNumero;
        if (Double.TryParse(Numero, NumberStyles.Float, ReferenciaCultural, out dNumero))
            return ToCardinal(dNumero);
        else throw new ArgumentException("'" + Numero + "' no es un número válido.");
    }

    public static String ToCardinal(String Numero)
    {
        return Numalet.ToCardinal(Numero, CultureInfo.CurrentCulture);
    }

    public static String ToCardinal(Decimal Numero)
    {
        return Convertir(Numero, DecimalesDefault, SeparadorDecimalSalidaDefault, MascaraSalidaDecimalDefault, true, LetraCapitalDefault, ConvertirDecimalesDefault, ApocoparUnoParteEnteraDefault, ApocoparUnoParteDecimalDefault);
    }

    #endregion

    private static String Convertir(Decimal Numero, Int32 Decimales, String SeparadorDecimalSalida, String MascaraSalidaDecimal, Boolean EsMascaraNumerica, Boolean LetraCapital, Boolean ConvertirDecimales, Boolean ApocoparUnoParteEntera, Boolean ApocoparUnoParteDecimal)
    {
        Int64 Num;
        Int32 terna, centenaTerna, decenaTerna, unidadTerna, iTerna;
        String cadTerna;
        StringBuilder Resultado = new StringBuilder();

        Num = (Int64)Math.Abs(Numero);

        if (Num >= 1000000000000 || Num < 0) throw new ArgumentException("El número '" + Numero.ToString() + "' excedió los límites del conversor: [0;1.000.000.000.000)");
        if (Num == 0)
            Resultado.Append(" cero");
        else
        {
            iTerna = 0;
            while (Num > 0)
            {
                iTerna++;
                cadTerna = String.Empty;
                terna = (Int32)(Num % 1000);

                centenaTerna = (Int32)(terna / 100);
                decenaTerna = terna % 100;
                unidadTerna = terna % 10;

                if ((decenaTerna > 0) && (decenaTerna < 10))
                    cadTerna = _matriz[UNI, unidadTerna] + cadTerna;
                else if ((decenaTerna >= 10) && (decenaTerna < 20))
                    cadTerna = cadTerna + _matriz[DIECI, unidadTerna];
                else if (decenaTerna == 20)
                    cadTerna = cadTerna + " veinte";
                else if ((decenaTerna > 20) && (decenaTerna < 30))
                    cadTerna = " veinti" + _matriz[UNI, unidadTerna].Substring(1);
                else if ((decenaTerna >= 30) && (decenaTerna < 100))
                    if (unidadTerna != 0)
                        cadTerna = _matriz[DECENA, (Int32)(decenaTerna / 10)] + " y" + _matriz[UNI, unidadTerna] + cadTerna;
                    else
                        cadTerna += _matriz[DECENA, (Int32)(decenaTerna / 10)];

                switch (centenaTerna)
                {
                    case 1:
                        if (decenaTerna > 0) cadTerna = " ciento" + cadTerna;
                        else cadTerna = " cien" + cadTerna;
                        break;
                    case 5:
                    case 7:
                    case 9:
                        cadTerna = _matriz[CENTENA, (Int32)(terna / 100)] + cadTerna;
                        break;
                    default:
                        if ((Int32)(terna / 100) > 1) cadTerna = _matriz[UNI, (Int32)(terna / 100)] + "cientos" + cadTerna;
                        break;
                }
                //Reemplazo el 'uno' por 'un' si no es en las únidades o si se solicító apocopar
                if ((iTerna > 1 | ApocoparUnoParteEntera) && decenaTerna == 21)
                    cadTerna = cadTerna.Replace("veintiuno", "veintiún");
                else if ((iTerna > 1 | ApocoparUnoParteEntera) && unidadTerna == 1 && decenaTerna != 11)
                    cadTerna = cadTerna.Substring(0, cadTerna.Length - 1);
                //Acentúo 'veintidós', 'veintitrés' y 'veintiséis'
                else if (decenaTerna == 22) cadTerna = cadTerna.Replace("veintidos", "veintidós");
                else if (decenaTerna == 23) cadTerna = cadTerna.Replace("veintitres", "veintitrés");
                else if (decenaTerna == 26) cadTerna = cadTerna.Replace("veintiseis", "veintiséis");

                //Completo miles y millones
                switch (iTerna)
                {
                    case 3:
                        if (Numero < 2000000) cadTerna += " millón";
                        else cadTerna += " millones";
                        break;
                    case 2:
                    case 4:
                        if (terna > 0) cadTerna += " mil";
                        break;
                }
                Resultado.Insert(0, cadTerna);
                Num = (Int32)(Num / 1000);
            } //while
        }

        //Se agregan los decimales si corresponde
        if (Decimales > 0)
        {
            Resultado.Append(" " + SeparadorDecimalSalida + " ");
            Int32 EnteroDecimal = (Int32)Math.Round((Double)(Numero - (Int64)Numero) * Math.Pow(10, Decimales), 0);
            if (ConvertirDecimales)
            {
                Boolean esMascaraDecimalDefault = MascaraSalidaDecimal == MascaraSalidaDecimalDefault;
                Resultado.Append(Convertir((Decimal)EnteroDecimal, 0, null, null, EsMascaraNumerica, false, false, (ApocoparUnoParteDecimal && !EsMascaraNumerica/*&& !esMascaraDecimalDefault*/), false) + " "
                    + (EsMascaraNumerica ? "" : MascaraSalidaDecimal));
            }
            else
                if (EsMascaraNumerica) Resultado.Append(EnteroDecimal.ToString(MascaraSalidaDecimal));
                else Resultado.Append(EnteroDecimal.ToString() + " " + MascaraSalidaDecimal);
        }
        //Se pone la primer letra en mayúscula si corresponde y se retorna el resultado
        if (LetraCapital)
            return Resultado[1].ToString().ToUpper() + Resultado.ToString(2, Resultado.Length - 2);
        else
            return Resultado.ToString().Substring(1);
    }
}

Clase en VB:

Imports System
Imports System.Text
Imports System.Globalization

''' <summary>
''' Convierte números en su expresión numérica a su numeral cardinal
''' </summary>
Public NotInheritable Class Numalet

#Region "Miembros estáticos"

    Private Const UNI As Integer = 0, DIECI As Integer = 1, DECENA As Integer = 2, CENTENA As Integer = 3
    Private Shared _matriz As String(,) = New String(CENTENA, 9) { _
        {Nothing, " uno", " dos", " tres", " cuatro", " cinco", " seis", " siete", " ocho", " nueve"}, _
        {" diez", " once", " doce", " trece", " catorce", " quince", " dieciséis", " diecisiete", " dieciocho", " diecinueve"}, _
        {Nothing, Nothing, Nothing, " treinta", " cuarenta", " cincuenta", " sesenta", " setenta", " ochenta", " noventa"}, _
        {Nothing, Nothing, Nothing, Nothing, Nothing, " quinientos", Nothing, " setecientos", Nothing, " novecientos"}}
    Private Const [sub] As Char = CChar(ChrW(26))
    'Cambiar acá si se quiere otro comportamiento en los métodos de clase
    Public Const SeparadorDecimalSalidaDefault As String = "con"
    Public Const MascaraSalidaDecimalDefault As String = "00'/100.-'"
    Public Const DecimalesDefault As Int32 = 2
    Public Const LetraCapitalDefault As Boolean = False
    Public Const ConvertirDecimalesDefault As Boolean = False
    Public Const ApocoparUnoParteEnteraDefault As Boolean = False
    Public Const ApocoparUnoParteDecimalDefault As Boolean = False

#End Region

#Region "Propiedades"

    Private _decimales As Int32 = DecimalesDefault
    Private _cultureInfo As CultureInfo = Globalization.CultureInfo.CurrentCulture
    Private _separadorDecimalSalida As String = SeparadorDecimalSalidaDefault
    Private _posiciones As Int32 = DecimalesDefault
    Private _mascaraSalidaDecimal As String, _mascaraSalidaDecimalInterna As String = MascaraSalidaDecimalDefault
    Private _esMascaraNumerica As Boolean = True
    Private _letraCapital As Boolean = LetraCapitalDefault
    Private _convertirDecimales As Boolean = ConvertirDecimalesDefault
    Private _apocoparUnoParteEntera As Boolean = False
    Private _apocoparUnoParteDecimal As Boolean

    ''' <summary>
    ''' Indica la cantidad de decimales que se pasarán a entero para la conversión
    ''' </summary>
    ''' <remarks>Esta propiedad cambia al cambiar MascaraDecimal por un valor que empieze con '0'</remarks>
    Public Property Decimales() As Int32
        Get
            Return _decimales
        End Get
        Set(ByVal value As Int32)
            If value > 10 Then
                Throw New ArgumentException(value.ToString() + " excede el número máximo de decimales admitidos, solo se admiten hasta 10.")
            End If
            _decimales = value
        End Set
    End Property

    ''' <summary>
    ''' Objeto CultureInfo utilizado para convertir las cadenas de entrada en números
    ''' </summary>
    Public Property CultureInfo() As CultureInfo
        Get
            Return _cultureInfo
        End Get
        Set(ByVal value As CultureInfo)
            _cultureInfo = value
        End Set
    End Property

    ''' <summary>
    ''' Indica la cadena a intercalar entre la parte entera y la decimal del número
    ''' </summary>
    Public Property SeparadorDecimalSalida() As String
        Get
            Return _separadorDecimalSalida
        End Get
        Set(ByVal value As String)
            _separadorDecimalSalida = value
            'Si el separador decimal es compuesto, infiero que estoy cuantificando algo,
            'por lo que apocopo el "uno" convirtiéndolo en "un"
            If value.Trim().IndexOf(" ") > 0 Then
                _apocoparUnoParteEntera = True
            Else
                _apocoparUnoParteEntera = False
            End If
        End Set
    End Property

    ''' <summary>
    ''' Indica el formato que se le dara a la parte decimal del número
    ''' </summary>
    Public Property MascaraSalidaDecimal() As String
        Get
            If Not [String].IsNullOrEmpty(_mascaraSalidaDecimal) Then
                Return _mascaraSalidaDecimal
            Else
                Return ""
            End If
        End Get
        Set(ByVal value As String)
            'determino la cantidad de cifras a redondear a partir de la cantidad de '0' o ''
            'que haya al principio de la cadena, y también si es una máscara numérica
            Dim i As Integer = 0
            While i < value.Length AndAlso (value(i) = "0"c OrElse value(i) = "#")
                i += 1
            End While
            _posiciones = i
            If i > 0 Then
                _decimales = i
                _esMascaraNumerica = True
            Else
                _esMascaraNumerica = False
            End If
            _mascaraSalidaDecimal = value
            If _esMascaraNumerica Then
                _mascaraSalidaDecimalInterna = value.Substring(0, _posiciones) + "'" + value.Substring(_posiciones).Replace("''", [sub].ToString()).Replace("'", [String].Empty).Replace([sub].ToString(), "'") + "'"
            Else
                _mascaraSalidaDecimalInterna = value.Replace("''", [sub].ToString()).Replace("'", [String].Empty).Replace([sub].ToString(), "'")
            End If
        End Set
    End Property

    ''' <summary>
    ''' Indica si la primera letra del resultado debe estár en mayúscula
    ''' </summary>
    Public Property LetraCapital() As Boolean
        Get
            Return _letraCapital
        End Get
        Set(ByVal value As Boolean)
            _letraCapital = value
        End Set
    End Property

    ''' <summary>
    ''' Indica si se deben convertir los decimales a su expresión nominal
    ''' </summary>
    Public Property ConvertirDecimales() As Boolean
        Get
            Return _convertirDecimales
        End Get
        Set(ByVal value As Boolean)
            _convertirDecimales = value
            _apocoparUnoParteDecimal = value
            If value Then
                ' Si la máscara es la default, la borro
                If _mascaraSalidaDecimal = MascaraSalidaDecimalDefault Then
                    MascaraSalidaDecimal = ""
                End If
            ElseIf [String].IsNullOrEmpty(_mascaraSalidaDecimal) Then
                MascaraSalidaDecimal = MascaraSalidaDecimalDefault
                'Si no hay máscara dejo la default
            End If
        End Set
    End Property

    ''' <summary>
    ''' Indica si de debe cambiar "uno" por "un" en las unidades.
    ''' </summary>
    Public Property ApocoparUnoParteEntera() As Boolean
        Get
            Return _apocoparUnoParteEntera
        End Get
        Set(ByVal value As Boolean)
            _apocoparUnoParteEntera = value
        End Set
    End Property

    ''' <summary>
    ''' Determina si se debe apococopar el "uno" en la parte decimal
    ''' </summary>
    ''' <remarks>El valor de esta propiedad cambia al setear ConvertirDecimales</remarks>
    Public Property ApocoparUnoParteDecimal() As Boolean
        Get
            Return _apocoparUnoParteDecimal
        End Get
        Set(ByVal value As Boolean)
            _apocoparUnoParteDecimal = value
        End Set
    End Property

#End Region

#Region "Constructores"

    Public Sub New()
        MascaraSalidaDecimal = MascaraSalidaDecimalDefault
        SeparadorDecimalSalida = SeparadorDecimalSalidaDefault
        LetraCapital = LetraCapitalDefault
        ConvertirDecimales = _convertirDecimales
    End Sub

    Public Sub New(ByVal ConvertirDecimales As Boolean, ByVal MascaraSalidaDecimal As String, ByVal SeparadorDecimalSalida As String, ByVal LetraCapital As Boolean)
        If Not [String].IsNullOrEmpty(MascaraSalidaDecimal) Then
            Me.MascaraSalidaDecimal = MascaraSalidaDecimal
        End If
        If Not [String].IsNullOrEmpty(SeparadorDecimalSalida) Then
            _separadorDecimalSalida = SeparadorDecimalSalida
        End If
        _letraCapital = LetraCapital
        _convertirDecimales = ConvertirDecimales
    End Sub

#End Region

#Region "Conversores de instancia"

    Public Function ToCustomCardinal(ByVal Numero As Double) As String
        Return Convertir(Convert.ToDecimal(Numero), _decimales, _separadorDecimalSalida, _mascaraSalidaDecimalInterna, _esMascaraNumerica, _letraCapital, _
        _convertirDecimales, _apocoparUnoParteEntera, _apocoparUnoParteDecimal)
    End Function

    Public Function ToCustomCardinal(ByVal Numero As String) As String
        Dim dNumero As Double
        If [Double].TryParse(Numero, NumberStyles.Float, _cultureInfo, dNumero) Then
            Return ToCustomCardinal(dNumero)
        Else
            Throw New ArgumentException("'" + Numero + "' no es un número válido.")
        End If
    End Function

    Public Function ToCustomCardinal(ByVal Numero As Decimal) As String
        Return ToCardinal(Numero)
    End Function

    Public Function ToCustomCardinal(ByVal Numero As Int32) As String
        Return Convertir(Convert.ToDecimal(Numero), 0, _separadorDecimalSalida, _mascaraSalidaDecimalInterna, _esMascaraNumerica, _letraCapital, _
        _convertirDecimales, _apocoparUnoParteEntera, False)
    End Function

#End Region

#Region "Conversores estáticos"

    Public Shared Function ToCardinal(ByVal Numero As Int32) As String
        Return Convertir(Convert.ToDecimal(Numero), 0, Nothing, Nothing, True, LetraCapitalDefault, _
        ConvertirDecimalesDefault, ApocoparUnoParteEnteraDefault, ApocoparUnoParteDecimalDefault)
    End Function

    Public Shared Function ToCardinal(ByVal Numero As Double) As String
        Return Convertir(Convert.ToDecimal(Numero), DecimalesDefault, SeparadorDecimalSalidaDefault, MascaraSalidaDecimalDefault, True, LetraCapitalDefault, _
        ConvertirDecimalesDefault, ApocoparUnoParteEnteraDefault, ApocoparUnoParteDecimalDefault)
    End Function

    Public Shared Function ToCardinal(ByVal Numero As String, ByVal ReferenciaCultural As CultureInfo) As String
        Dim dNumero As Double
        If [Double].TryParse(Numero, NumberStyles.Float, ReferenciaCultural, dNumero) Then
            Return ToCardinal(dNumero)
        Else
            Throw New ArgumentException("'" + Numero + "' no es un número válido.")
        End If
    End Function

    Public Shared Function ToCardinal(ByVal Numero As String) As String
        Return Numalet.ToCardinal(Numero, CultureInfo.CurrentCulture)
    End Function

    Public Shared Function ToCardinal(ByVal Numero As Decimal) As String
        Return ToCardinal(Convert.ToDouble(Numero))
    End Function

#End Region

    Private Shared Function Convertir(ByVal Numero As Decimal, ByVal Decimales As Int32, ByVal SeparadorDecimalSalida As String, ByVal MascaraSalidaDecimal As String, ByVal EsMascaraNumerica As Boolean, ByVal LetraCapital As Boolean, _
    ByVal ConvertirDecimales As Boolean, ByVal ApocoparUnoParteEntera As Boolean, ByVal ApocoparUnoParteDecimal As Boolean) As String
        Dim Num As Int64
        Dim terna As Int32, centenaTerna As Int32, decenaTerna As Int32, unidadTerna As Int32, iTerna As Int32
        Dim cadTerna As String
        Dim Resultado As New StringBuilder()

        Num = Math.Floor(Math.Abs(Numero))

        If Num >= 1000000000001 OrElse Num < 0 Then
            Throw New ArgumentException("El número '" + Numero.ToString() + "' excedió los límites del conversor: [0;1.000.000.000.001]")
        End If
        If Num = 0 Then
            Resultado.Append(" cero")
        Else
            iTerna = 0

            Do Until Num = 0

                iTerna += 1
                cadTerna = String.Empty
                terna = Num Mod 1000

                centenaTerna = Int(terna / 100)
                decenaTerna = terna - centenaTerna * 100 'Decena junto con la unidad
                unidadTerna = (decenaTerna - Math.Floor(decenaTerna / 10) * 10)

                Select Case decenaTerna
                    Case 1 To 9
                        cadTerna = _matriz(UNI, unidadTerna) + cadTerna
                    Case 10 To 19
                        cadTerna = cadTerna + _matriz(DIECI, unidadTerna)
                    Case 20
                        cadTerna = cadTerna + " veinte"
                    Case 21 To 29
                        cadTerna = " veinti" + _matriz(UNI, unidadTerna).Substring(1)
                    Case 30 To 99
                        If unidadTerna <> 0 Then
                            cadTerna = _matriz(DECENA, Int(decenaTerna / 10)) + " y" + _matriz(UNI, unidadTerna) + cadTerna
                        Else
                            cadTerna += _matriz(DECENA, Int(decenaTerna / 10))
                        End If
                End Select

                Select Case centenaTerna
                    Case 1
                        If decenaTerna > 0 Then
                            cadTerna = " ciento" + cadTerna
                        Else
                            cadTerna = " cien" + cadTerna
                        End If
                        Exit Select
                    Case 5, 7, 9
                        cadTerna = _matriz(CENTENA, Int(terna / 100)) + cadTerna
                        Exit Select
                    Case Else
                        If Int(terna / 100) > 1 Then
                            cadTerna = _matriz(UNI, Int(terna / 100)) + "cientos" + cadTerna
                        End If
                        Exit Select
                End Select
                'Reemplazo el 'uno' por 'un' si no es en las únidades o si se solicító apocopar
                If (iTerna > 1 OrElse ApocoparUnoParteEntera) AndAlso decenaTerna = 21 Then
                    cadTerna = cadTerna.Replace("veintiuno", "veintiún")
                ElseIf (iTerna > 1 OrElse ApocoparUnoParteEntera) AndAlso unidadTerna = 1 AndAlso decenaTerna <> 11 Then
                    cadTerna = cadTerna.Substring(0, cadTerna.Length - 1)
                    'Acentúo 'veintidós', 'veintitrés' y 'veintiséis'
                ElseIf decenaTerna = 22 Then
                    cadTerna = cadTerna.Replace("veintidos", "veintidós")
                ElseIf decenaTerna = 23 Then
                    cadTerna = cadTerna.Replace("veintitres", "veintitrés")
                ElseIf decenaTerna = 26 Then
                    cadTerna = cadTerna.Replace("veintiseis", "veintiséis")
                End If

                'Completo miles y millones
                Select Case iTerna
                    Case 3
                        If Numero < 2000000 Then
                            cadTerna += " millón"
                        Else
                            cadTerna += " millones"
                        End If
                    Case 2, 4
                        If terna > 0 Then cadTerna += " mil"
                End Select
                Resultado.Insert(0, cadTerna)
                Num = Int(Num / 1000)
            Loop
        End If

        'Se agregan los decimales si corresponde
        If Decimales > 0 Then
            Resultado.Append(" " + SeparadorDecimalSalida + " ")
            Dim EnteroDecimal As Int32 = Int(Math.Round((Numero - Int(Numero)) * Math.Pow(10, Decimales)))
            If ConvertirDecimales Then
                Dim esMascaraDecimalDefault As Boolean = MascaraSalidaDecimal = MascaraSalidaDecimalDefault
                Resultado.Append(Convertir(Convert.ToDecimal(EnteroDecimal), 0, Nothing, Nothing, EsMascaraNumerica, False, _
                False, (ApocoparUnoParteDecimal AndAlso Not EsMascaraNumerica), False) + " " + (IIf(EsMascaraNumerica, "", MascaraSalidaDecimal)))
            ElseIf EsMascaraNumerica Then
                Resultado.Append(EnteroDecimal.ToString(MascaraSalidaDecimal))
            Else
                Resultado.Append(EnteroDecimal.ToString() + " " + MascaraSalidaDecimal)
            End If
        End If
        'Se pone la primer letra en mayúscula si corresponde y se retorna el resultado
        If LetraCapital Then
            Return Resultado(1).ToString().ToUpper() + Resultado.ToString(2, Resultado.Length - 2)
        Else
            Return Resultado.ToString().Substring(1)
        End If
    End Function

End Class




Chequear todos los checkbox de un gridview con javascript

3 11 2007

NOTA 16/06/2009: He publicado como hacer esta misma tarea con jQuery en este artículo, recomiendo utilizar esa alternativa por los motivos que comento en dicho artículo.

Muchas veces queremos chequear o deschequear todos los checkbox de un gridview de una sola vez, puede ser cuando el usuario realiza determinada acción en otro control, por ejemplo un botón para “seleccionar todo”, o que cuando se activa un CheckBox en la cabecera de la columna, cambie el estado de todos los checks al de la cabecera.

Si bien esto se puede hacer con código de servidor, normalmente no queremos que se realice un postback solo para esto, la solución es javascript.

(Nuevamente, si solo te interesa un código js que solucione el problema que acabo de describir, te recomiendo ir directamente a copiar los dos fragmentos de javascript al final del post.)

En su momento utilicé el código que Scott Mitchell publicó en este artículo, pero esta solución, si bien funciona y no genera un postback, requiere código de servidor, ya que en la creación de la página se registra mediante el método RegisterArrayDeclaration un array con los ids que tendrá cada checkbox en el cliente, lo que nos obliga a hacerlo en cada postback o a recargar el estado, ya sea ViewState o Session.

Buscando otra alternativa encontré este otro artículo de Mohammad Azam, el autor de GridViewGuy. Este si es un ejemplo totalmente sobre javascript, pero tiene un gran defecto: opera sobre todos los checkbox de la página, sin importar si están dentro de la grilla o no, ni hablar si tenemos dos grillas.

En este punto decidí escribir mi propia solución, o sea una totalmente en el cliente y que permita operar sobre los checkbox de una grilla en particular y, por que no, sobre una columna en particular, de manera que podamos tener más de una columna con checkbox y cambiar el estado de los checks de una de ellas sin afectar la otra.

Un poco de background que pueden saltarse si les parece:

Como incluir CheckBox en una columna de un gridview

Antes que nada hay que generar una columna con los checkbox. Hay dos maneras de hacer esto como cuenta Scott Mitchell en el artículo nombrado, yo utilizo también un TemplateField (no encontré todavía utilidad para los CheckBoxField), de modo que colocando un checkbox en el ItemTemplate genero uno en cada celda del TemplateField:

<asp:TemplateField HeaderText="chk">
   <ItemTemplate>
      <asp:CheckBox ID="chkColumna1" runat="server" />
   </ItemTemplate>
</asp:TemplateField>

Visualmente (en VisualStudio), agregamos un TemplateField al gridview y seleccionamos EditTemplate del menú contextual. Luego arrastramos un CheckBox a la zona ItemTemplate. Para finalizar: End Template Editing, en el menú contextual.

Como chequear todos CheckBox de un GridView

Esta rutina javascript cambia el estado de todos los checkbox de una grilla sin importar su ubicación:

function ChangeAllChecks(gridViewName,newState)
{
   var tabla = document.getElementById(gridViewName);
   celdas = tabla.cells;
   for(i=0;i<celdas.length-1;i++)
   {
   if (celdas[i].firstChild.type=="checkbox"
   && celdas[i].firstChild.checked != newState)
      {
         celdas[i].firstChild.click();
      }
   }
}

Para llamarla utilizamos ChangeAllChecks(‘GridView1’,true); o false si es el caso

Como chequear una columna específica

Puede ocurrir que en un gridview tengamos más de un TemplateField con CheckBox, y que solo queramos chequear o deschequear una sola columna, para ello hice un nuevo método que toma como paramétro el índice de la columna (empezando por 0). (Actualizado 29/09/2008 según respuesta al comentario de Martín)

function ChangeChecksByColumn(gridViewName, newState, columnIndex){
    var tabla = document.getElementById(gridViewName);
    var columnas = tabla.cells.length / tabla.rows.length;
    celdas = tabla.cells;
    for (i = columnas + columnIndex; i < celdas.length; i += columnas){
        if (celdas[i].firstChild.type == "checkbox"
               && celdas[i].firstChild.checked != newState
            /* && agregar aquí otras condiciones */){
            celdas[i].firstChild.click();
        }
    }
}

Como chequear a partir de un CheckBox en la cabecera

El código anterior no es muy útil porque nos solicita el estado (true=checked/false=unchequed) en el que queremos dejar los checkbox, la solución es agregar un checkbox en la cabecera de la TemplateColumn:

y agregamos una rutina que permita identificar el estado del mismo y la columna en que se encuentra, luego llamamos al método anterior: ChangeChecksByColumn:

function CopyCheckStateByColumn(HeaderCheckBox, gridViewName)
{
    var columnIndex = HeaderCheckBox.parentElement.cellIndex;
    var newState = HeaderCheckBox.checked;
    ChangeChecksByColumn(gridViewName, newState, columnIndex);
}

Para terminar, completamos el evento onclick del checkbox con una llamada a CopyCheckStateByColumn pasando como parámetro el propio checkbox y el nombre del gridview:
(lo que sigue cambiado el 08/11 según la respuesta al comentario de Roger)

<asp:TemplateField HeaderText="chk">
<HeaderTemplate>
    <asp:CheckBox ID="chkHeader" runat="server" onclick="javascript:CopyCheckStateByColumn(this,this.offsetParent.offsetParent.id);"/>
</HeaderTemplate>
<ItemTemplate>
    <asp:CheckBox ID="chkTest" runat="server" />
</ItemTemplate>
</asp:TemplateField>

Una precaución: Tener en cuenta que el código javascript consulta si el contenido es un CheckBox mediante la propiedad firstChild para cada objeto de la colección cells del objeto table, o sea: el contenido de cada TD. Si ponemos otro objeto en el ItemTemplate junto con el CheckBox, podemos tener problemas si nos descuidamos.

Un tip: Para llamar métodos javascript en el evento click de un control Button, se debe utilizar el evento OnClientClick en vez de onclick.

Una desventaja: No funciona en Firefox.

Saludos





Envío de mail con framework 2.0, mi clase con autenticación

30 10 2007

A todos nos llega en algún momento la necesidad de que nuestro sistema envíe mails. Es ahí donde donde toda nuestra pasión por la programación se reduce a google y “ojala que alguién lo tenga resuelto”.

No es difícil encontrar como enviar mails con .Net, pero luego nos encontramos con diferentes requerimientos de formato, seguridad, etc. y como no queremos interiorizarnos en como MS implementó el aburrido protocolo SMTP en el framework, recurrimos nuevamente a google para aburrirnos un rato más. Bueno, he aquí el producto de mi tiempo desperdiciado y algunos comentarios sobre el tema. Si solamente necesitás una clase para enviar mails sin mucha vuelta te recomiendo ir directamente al código y aplicar un “copy-paste”, ya que el código es bastante auto-explicativo y hay un solo método para utilizar.

El namespace utilizado es System.Net.Mail (solo framework 2.0, antes era System.Web.Mail)

Basicamente, lo que hacemos es:

  • Crear un objeto MailMessage
  • Crear y asignar a la propiedad “From” del objeto MailMessage un objeto MailAddress con la dirección de salida
  • Crear y asignar a la propiedad “To” del objeto MailMessage una colección de objetos MailAddress conteniendo las direcciones de los destinatarios
  • Asignar un texto a las propiedades “Subject” y Body” del objeto MailMessage
  • Crear un objeto SmtpClient, definirle la dirección del host y el puerto, e invocar a su método Send pasándole como parámetro nuestro MailMessage

Esa es la manera de enviar un mail simple, con un body de texto plano, sin attachments y por el puerto 25 (default de SMTP), sin embargo, el único método público de mi clase (Enviar) tiene una serie de parámetros opcionales (serán sobrecargas para quien lo quiera pasar a C#) que permiten utilizar otras características:

MailPort permite especificar la utilización de otro puerto para conectarse al host SMTP (por ejemplo 443 para utilizar ssl), altarando la propiedad Port del SmtpClient .

MailIsBodyHtml permite especificar si el texto enviado es Html o texto plano (propiedad IsBodyHtml del objeto MailMessage).

EnableSSL determina si se utiliza (1), o no (0) este protocolo en la conexión con el host (propiedad EnableSsl de SmtpClient).

SSLuser y SSLpass permite enviar al host un user y password para establecer la conexión (propiedad Credentials de SmtpClient).

En el código no manejo attachments, pero para quien deba hacerlo, estos se incluyen por medio instancias de la clase System.Net.Attachment, que permite especificar un path o un stream como origen, el tipo de contenido (su mimetype) y algunas propiedades más. Estos objetos se adjuntan mediante el método Add de la colección Attachments del objeto MailMessage. Cuando pueda probar la clase con archivos adjuntos en un ambiente real, actualizo el código comentando si hubo algún issue al respecto.

Respecto al body en formato html, se puede comentar, aunque hablamos de SMTP y no de .Net, que si incluimos imágenes, nuestro html puede hacer referencia a imágenes hosteadas en algún server o a imágenes adjuntas, en cuyo caso se incluye solo el nombre de la imágen en el tag img.

Autenticación en un host con ssl

Donde hay muchas veces un gran problema, es en el uso de SSL para conectarse al host SMTP. Ocurre que para establecer el canal SSL el host utiliza un certificado, nuestra máquina cliente intentará validar ese certificado contra las autoridades certificantes registradas un su propio repositorio, y si no lo consigue arrojará una excepción con el fatídico texto “The underlying connection was closed: Could not establish trust relationship for the SSL/TLS secure channel” y dentro de ella otra con el más aclaratorio: “The remote certificate is invalid according to the validation procedure“. Esto generalmente ocurre porque el certificado no está emitido por una autoridad (CA) reconocida en nuestra máquina cliente, entonces podemos:

  • Obtener el certificado root de la CA e instalarlo la máquina donde correrá nuestro sistema para que esta reconozca el certificado del host SMTP (esto no pasa mucho más lejos de un click derecho en el archivo del certificado), normalmente le pediremos el mismo al administrador del host.
  • Solicitarle al responsable del host que pague un certificado de una CA reconocida, digamos VeriSign, etc. (esto siempre por teléfono o mail, nunca personalmente y si es el caso preparar antes un CV, por si nos quedamos sin cliente por insolentes)
  • Considerar que lo unico que nos interesa es que nuestro sistema envíe los condenados mails y forzar al framework a realizar una validación personalizada o a aceptar como válido cualquier certificado que el host posea (está última la opción del ejemplo)

Para ello (la tercera opción) el framework 2.0 nos expone una propiedad del objeto ServicePointManager, ServerCertificateValidationCallback, que nos permite especificar un delegado a una función que se encargue de validar el certificado a nuestro gusto, por ejemplo, retornando siempre true. Si quisieramos realizar una validación personalizada, como chequear el DN del certificado, deberíamos hacerlo aquí, ya que nuestro método de validación recibe el certificado en un parámetro, permitiendos acceder a todas sus propiedades.

Es importante destacar que esto último se aplica también a cualquier conexión http vía ssl realizada por el framework, llámese Web Service o HttpWebRequest.

Por último aquí un sitio dedicado al namespace System.Net.Mail y, si necesitamos tocar los headers del mensaje, este artículo de Scott Mitchell (cuando no) desde donde podemos acceder también a otros artículos de su autoría sobre envío de mails con .Net en sus diferente versiones.

Public Class Correo


    Public Shared Sub Enviar(ByVal MailDestinatarios As System.Net.Mail.MailAddressCollection, _
    ByVal MailDireccionOrigen As String, _
    ByVal MailNombreOrigen As String, _
    ByVal MailSubject As String, _
    ByVal MailBody As String, _
    ByVal MailHost As String, _
    Optional ByVal MailPort As Integer = 25, _
    Optional ByVal MailIsBodyHtml As Boolean = True, _
    Optional ByVal EnableSSL As Integer = 0, _
    Optional ByVal SSLuser As String = "", _
    Optional ByVal SSLpass As String = "")

        'Se crea el mensaje
        Dim oMensaje As New MailMessage

        'Se crea el cliente SMTP
        Dim oSMTP As New SmtpClient()

        'Se agregan las direcciones
        oMensaje.From = New MailAddress(MailDireccionOrigen, MailNombreOrigen)
        For Each addr As MailAddress In MailDestinatarios
            oMensaje.To.Add(addr)
        Next

        oMensaje.Subject = MailSubject
        oMensaje.Body = MailBody
        oMensaje.IsBodyHtml = MailIsBodyHtml
        'Seteo que el server notifique solamente en el error de entrega
        oMensaje.DeliveryNotificationOptions = DeliveryNotificationOptions.OnFailure

        'servidor smtp (dirección IP ó nombre de host)
        oSMTP.Host = MailHost
        'Puerto a utilizar (25 por defecto)
        oSMTP.Port = MailPort

        'Si se solicitó SSL, lo activo
        If EnableSSL = 1 Then
            oSMTP.EnableSsl = True
            'Bypass de validación de certificado (para problemas con servidores de SMTP con SSL con certificados que no validan en nuestra máquina)
            ServicePointManager.ServerCertificateValidationCallback = New RemoteCertificateValidationCallback(AddressOf ValidarCertificado)
        End If
        'Cargo las credenciales si hacen falta
        If Not String.IsNullOrEmpty(SSLuser) Then
            Dim credenciales As New System.Net.NetworkCredential(SSLuser, SSLpass)
            oSMTP.Credentials = credenciales
        End If

        Try
            oSMTP.Send(oMensaje)
        Catch ex As SmtpException
            Throw 'Aqui rutina de comprobación propia y logueo de excepción
        Catch ex As Exception
            Throw 'Aqui logueo de excepción
        Finally
            oMensaje = Nothing
            oSMTP = Nothing
        End Try
    End Sub


    Private Shared Function ValidarCertificado(ByVal sender As Object, ByVal certificate As X509Certificate, ByVal chain As X509Chain, ByVal sslPolicyErrors As System.Net.Security.SslPolicyErrors) As Boolean
        'bypass de la validación del certificado (aplicar aquí validación personalizada si fuera el caso)
        Return True
    End Function
End Class

Cuando actualice el código con utilización de archivos adjuntos y/o la versión en C# avisaré como comentario del artículo.

Saludos.





¿Por que usar los tipos de datos de .NET Framework?

23 10 2007

Leyendo un poco en la Web, sobre todo sobre C#, es común encontrar a quien recomienda como buena práctica de programación, utilizar las palabras claves propias del lenguaje para los tipos de datos, en vez de utilizar las clases del Framework. No estoy de acuerdo con esto, no tanto con el enunciado, si no en clasificarlo como “buena práctica”.

Utilizar una u otra nomenclatura es indiferente para la ejecución del código, ya que los tipos de datos del lenguaje, tanto VB como C# son solo shorcuts a los tipos de nativos de .NET. Veamos que hay a favor y en contra de utilizar los tipos del framework:

  • (+) Es particularmente útil para quienes programamos en ambos lenguajes, por más asimilados que tengamos o no ambas sintaxis, siempre es más fácil utilizar la misma en ambos lenguajes, y aunque se utilize uno solo, será más facil para nosotros cuando tengamos que aprender el otro. También hay que considerar que no sabemos que background tendrá quien lea nuestro código en el futuro.
  • (+) El Visual Studio 2005+ resalta (solo C#) en un feo color ¿turquesa? los tipos de datos del framework, diferenciándolos del resto de las palabras claves.
  • (+) La sintaxis será soportada en futuros lenguajes .NET
  • (-) Hay que incluir un Imports/using del namespace System (aunque Visual Studio lo incluye en la mayoría, si no en todos los documentos.)
  • (-) Quienes venimos de otros lenguajes ya tenemos una sintaxis incorporada.
  • (-) Personas provenientes de otros lenguajes, particularmente C++, pueden ver afectada su virilidad.

Como vemos no hay razones de peso para utilizar uno u otro estándar, pocas para hacer una recomendación y creo que ninguna para calificar de “buena práctica” una u otra nomenclatura por sobre otra.

Para completar, algunos tips que no siempre se tienen presente y una tabla comparativa entre los dos lenguajes y el framework:

  • Short es más lento que integer, aunque pueda ocupar menos memoria.
  • Interger es el tipo numérico más eficiente en términos de rendimiento.
  • Los enteros sin signo (UInt16/32/64) son igualmente rápidos que los signados y con mayor capacidad, siempre que se sepa que solo se almacenaran enteros positivos, pero no son cls compliant.
  • Decimal” es el tipo con mayor rango pero las operaciones con este tipo son mucho más lentas que con el resto
    Double es el tipo real más rapido (en plataformas de 32 bits)
  • A partir del framework 3.5, existe una nueva estructura para albergar fechas y horas: DataTimeOffset, que maneja zonas UTC. Si bien sigue existiendo DateTime, DateTimeOffset será la recomendación de Microsoft.

Tabla comparativa

VB.NET C# .NET .NET Framework Almacenamiento Intervalo
Boolean bool Boolean true/false
Byte byte Byte 1 byte [0;255]
Short short Int16 2 bytes [-32768;32767]
Integer int Int32 4 bytes [-2147483648;2147483647]
Long long Int64 8 bytes [-10E19;10E19]*
Single float Single 4 bytes [-10E38;10E38]*
Double double Double 8 bytes [-10E308;10E308]*
Decimal decimal Decimal 12 bytes (parte entera) [-10E28;10E28]*
Date System.DateTime DateTime 8 bytes [1/1/0001;31/12/9999]
String string String
Char char Char 2 bytes
Object object Object 4bytes (32 bits) / 8 bytes (64 bits)
SByte sbyte Sbyte 1 byte [-128/127]
UShort ushort UInt16 2 bytes [0:65535]
UInteger uint UInt32 4 bytes [0;4.294.967.295]
ULong ulong UInt64 8 bytes [0;1,8E19]

* datos apróximados.

Saludos.





Fuslogvw ¿como encontrar una referencia perdida?

17 10 2007

Fusion es basicamente la tecnología utilizada en .NET para cargar los ensamblados en memoria. Es Fusion quien no encuentra nuestras referencias perdidas, y quien escribe un log que nos sirve para averiguar el porqué de un problema de este tipo.

Hay muchas circunstancias en las que podemos tener problemas para encontrar un ensamblado: versionado de assemblies, referencias desactualizadas, proyectos que se movieron, deployment, etc.

Cuando no encuentra un ensamblado, .NET (Fusion en particular) registra en un log las ubicaciones en donde Fusion lo buscó.

fuslogvw

La interfase es muy sencilla y una vez que encontramos el error que buscamos accedemos a un log similar a este:

*** Assembly Binder Log Entry  (17/10/2007 @ 12:15:31 a.m.) ***

The operation failed.
Bind result: hr = 0x80070002. The system cannot find the file specified.

Assembly manager loaded from:  C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\mscorwks.dll
Running under executable  C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\devenv.exe
--- A detailed error log follows. 

=== Pre-bind state information ===
LOG: User = GS\Ale
LOG: DisplayName = Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration, Version=3.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 (Fully-specified)
LOG: Appbase = file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/
LOG: Initial PrivatePath = NULL
LOG: Dynamic Base = NULL
LOG: Cache Base = NULL
LOG: AppName = NULL
Calling assembly : System, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089.
===
LOG: This bind starts in default load context.
LOG: Using application configuration file: C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\devenv.exe.Config
LOG: Using machine configuration file from C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\config\machine.config.
LOG: Post-policy reference: Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration, Version=3.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
LOG: GAC Lookup was unsuccessful.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PublicAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PublicAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PrivateAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PrivateAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.DLL.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PublicAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PublicAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PrivateAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: Attempting download of new URL file:///C:/Program Files/Microsoft Visual Studio 8/Common7/IDE/PrivateAssemblies/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration/Microsoft.Practices.EnterpriseLibrary.Configuration.Design.VisualStudioIntegration.EXE.
LOG: All probing URLs attempted and failed.

En el, observamos lo datos del assembly que no logró bindear y las ubicaciones desde donde se intentó cargarlo.

Algunas cositas para tener en cuenta:

  • El log se guarda por defecto en el directorio de archivos temporales de Internet Explorer, si no se quieren perder datos se puede cambiar la ubicación desde la interfase.
  • También se puede configurar si se quieren ver solo los ensamblados con errores o todos los ensamblados bindeados. Esto puede ser muy útil si queremos controlar las referencias incluidas en nuestro proyecto, asegurandonos que no estamos levantando nada del lugar equivocado.
  • Previamente a la versión 2.0 del framework, fuslogvw.exe se configura mediante el registro de windows.

En esta página, un trabajo muy completo sobre Fusion, con mucha información de la GAC, Strong Name Key, etc. Incluyendo por supuesto fuslogvw.

Saludos





Operador ternario en c# y vb…y ??

11 10 2007

Algo creo que muy util de C# y que no existía en VB es el operador ternario, bueno en VB existe el IIf, pero hay una gran diferencia. Veamos:

En C#, la manera de utilizar el operador ternario es la siguiente

result = condición_lógica ? valor_si_verdadero : valor_si_falso;

Por ej.:

Int32 mayor = (a > b) ? a : b;

(los paréntesis no son necesarios, solo para legibilidad)

En este ejemplo clásico para devolver el mayor valor, si la condición lógica es verdadera ‘mayor’ valdrá a, caso contrario se le asignará b.

Ahora, que pasa si quiero utilizar el operador ‘?’ para lo siguiente:

String _usuario = (usuario != null) ? usuario : "invitado";

Bien, funciona, pero aquí puedo utilizar el operador ?? de C#, (o null coalescing operator) y obtener el mismo resultado con esta sentecia:

String _usuario = usuario ?? "invitado";

Este operador retorna el operando de la izquierda excepto cuando es nulo, donde retorna el de la derecha ‘invitado’ (ojo, no sirve para una cadena vacía, solo null)

Operador ternario en VB

A priori parecería que se puede utilizar el método IIf en VB como operador ternario, volviendo al primer ejemplo algo así como:

Dim mayor as Int32 = IIf(a > b, a, b)

¿funciona igual esto? sssNO. IIf es una función, por lo tanto retorna un tipo, ¿cual? Object. Y ahí se acabaron para mi todas la razones para utilizar IIf, ya que para no tener problemas y evitar casteos implicitos (una de las ‘licencias’ de VB que se aleja de la buena programación) tendríamos que convertir (CInt, CType…) el resultado antes de asignarlo a ‘mayor’.

Para colmo de males, como IIf es un método, todos los parámetros que se le pasan son evaluados previamente a la ejecución, por lo que se determinará el valor los dos posibles resultados sin importar cual sea el que retorne, más graficamente:

IIf(true, EstoSeEjecuta(), EstoYoNoQuisieraPeroSeEjecutaIgual())

Curioso: Si pasamos el código de C# a VB utilizando Reflector el operador ternario se refleja como IIf. Definitivamente Lutz sabe más que yo, pero creo que es una cuestión de legibilidad más que de exactitud.

En la MSDN David M. Kean propone hacer una función genérica, pero ¿que gracia tiene hacer una función, hacerla pública a todo el ámbito de nuetro código y sobre todo complicar la legibilidad del mismo que es la principal función del operador ternario, si no la única?

Todo esto hasta VB 8, en VB 9 tendremos la posibilidad de utilizar el operador ternario:

Dim mayor as Int32 = If(a > b ? a, b)

Tal vez se salga un poco de la clásica sintaxis de VB pero bienvenido sea.

En VB 9 (Agregado 11/2008): ¡Cuidado! Al ser el nuevo If de VB 9 un operador, ya no se calcula el valor de la expresión que no se retorna:

If(true, EstoSeEjecuta(), EstoNoSeEjecuta())

Pero de todas maneras el operador ¡Sigue retornando Object! y por lo tanto desaconsejo el uso del mismo para evitar boxing, casteos y demas temas.

Tambien hay que destacar que se agregó el null coalescing operator (o ??) de C#, con la siguiente sintaxis:

If(usuario, "Invitado")

Operador ternario y desempeño

En pruebas con Stopwatch, el desempeño es el mismo en ambos modos, no hay diferencia. A nivel IL el If genera más líneas, pero no redundan en el tiempo de ejecución.

Operador ternario y buenas prácticas de codificación

Acabo de leer por ahí a alguien jactandose de:

WriteLine("Login "+((condicion_booleana)?"":"in")+"válido.");

No estaré yo para competir en TopCoders, pero esto es lo que se llama algo muy muy poco recomendable, no pasa por el operador ternario el problema, si no por el hecho de querer ahorrar código haciendo malabares que después otro tendrá que entender. Esto es pereza no eficiencia.

Por último. En más de un documento donde se habla de buenas prácticas de codificación, recomiendan no usar el operador ternario, ¿por que?, bueno entiendo que lo que persiguen quienes hacen esta recomendación es código legible, pero lo que está mal es el abuso del operador, no su uso normal. Por ejemplo no es legible la sentecia:

cena = estoyPaNioquis ? HacerNioquis(harina, agua, huevos, pure):

PedirPizza(4866-2804,'Napo');

Cuando una línea excede la pantalla, ¿partimos la sentencia con el operador ternario en varias líneas? No. utilizamos If. Nuevamente la ventaja de este operador es unicamente la legibilidad del código, si no, no tiene gracia.

False: Me voy a comer pizza.





Sobre @@error, @@rowcount y porqué tener cuidado al utilizarlas juntas

6 10 2007

La manera de manejar los errores en sql server al estilo de la versión 2000 (o < ) es con @@error, que guarda el número de error que se produjo o 0 si no hubo ninguno; y en todas las versiones se utiliza @@rowcount para averiguar la cantidad de filas afectadas por una sentencia. Hay que tener en cuenta algunos detalles para evitar problemas con estas dos variables.

Ambas variables tienen el valor correspondiente luego de cada sentencia, pero este valor cambia en ambas al ejecutar otra, cualquiera que esta sea. Por ejemplo:

INSERT NOMBRES (NOMBRE, APELLIDO) VALUES ('MORCILLO','LOPEZ')
IF @@ERROR <> 0 THEN RETURN @@ERROR

Da como resultado 0 para @@error por mas que la inserción falle, debido a que luego de la sentencia IF se limpia el contenido de @@error. Una manera correcta de manejar esto sería:

DECLARE @@CODIGO_ERROR INT
INSERT NOMBRES (NOMBRE, APELLIDO) VALUES ('MORCILLO','LOPEZ')
SET @@CODIGO_ERROR = @@ERROR
IF @@CODIGO_ERROR <> 0 THEN RETURN @@CODIGO_ERROR

Garantizando así que el error retornado es correcto.

Sobre @@rowcount hay que tener las mismas precauciones, o sea, guardar en una variable el resultado siempre que lo necesitemos para algo.

Ahora ¿como hacer para utilizar ambas variables sin perder ninguna? La solución es asignar ambas en una sola sentencia:

DECLARE @@CODIGO_ERROR INT
DECLARE @@RCOUNT INT
INSERT NOMBRES (NOMBRE, APELLIDO) VALUES ('MORCILLO','LOPEZ')
SELECT @@CODIGO_ERROR = @@ERROR, @@RCOUNT = @@ROWCOUNT
IF @@CODIGO_ERROR <> 0 THEN RETURN @@CODIGO_ERROR
IF @@RCOUNT...

La sentencia Select es una sola y asigna las dos variables.

Sobre error handling en sql server, hay un artículo (en inglés) de Erland Sommarskog que recomiendo para tener una idea completa del tema. (así como todos los artículo de Erland, que son algo así como “la verdad” de cada tema de sql sobre el que publica)

Por último, me gusta ser cuidadoso el escribir sql, tabulaciones, alias legibles, etc. Por este mismo motivo me gusta poner las sentencias de error handling en una sola línea: (el ejemplo con rollback de la transacción)

SET @@CODIGO_ERROR = @@ERROR /**/ IF @@CODIGO_ERROR <> 0 THEN /**/
BEGIN /**/ ROLLBACK TRAN /**/ RETURN @@CODIGO_ERROR /**/ END

Cuestión de gustos…