How to convert float or currency to a localized string?
Asked Answered
F

2

15

In Delphi1, using FloatToStrF or CurrToStrF will automatically use the DecimalSeparator character to represent a decimal mark. Unfortunately DecimalSeparator is declared in SysUtils as Char1,2:

var 
  DecimalSeparator: Char;

While the LOCALE_SDECIMAL is allowed to be up to three characters:

Character(s) used for the decimal separator, for example, "." in "3.14" or "," in "3,14". The maximum number of characters allowed for this string is four, including a terminating null character.

This causes Delphi to fail to read the decimal separator correctly; falling back to assume a default decimal separator of ".":

DecimalSeparator := GetLocaleChar(DefaultLCID, LOCALE_SDECIMAL, '.');

On my computer, which is quite a character, this cause floating point and currency values to be incorrectly localized with a U+002E (full stop) decimal mark.

i am willing to call the Windows API functions directly, which are designed to convert floating point, or currency, values into a localized string:

Except these functions take a string of picture codes, where the only characters allowed are:

  • Characters "0" through "9" (U+0030..U+0039)
  • One decimal point (.) if the number is a floating-point value (U+002E)
  • A minus sign in the first character position if the number is a negative value (U+002D)

What would be a good way1 to convert a floating point, or currency, value to a string that obeys those rules? e.g.

  • 1234567.893332
  • -1234567

given that the local user's locale (i.e. my computer):


A horrible, horrible, hack, which i could use:

function FloatToLocaleIndependantString(const v: Extended): string;
var
   oldDecimalSeparator: Char;
begin
   oldDecimalSeparator := SysUtils.DecimalSeparator;
   SysUtils.DecimalSeparator := '.'; //Windows formatting functions assume single decimal point
   try
      Result := FloatToStrF(Value, ffFixed, 
            18, //Precision: "should be 18 or less for values of type Extended"
            9 //Scale 0..18.   Sure...9 digits before decimal mark, 9 digits after. Why not
      );
   finally
      SysUtils.DecimalSeparator := oldDecimalSeparator;
   end;
end;

Additional info on the chain of functions the VCL uses:

Note

1 in my version of Delphi
2 and in current versions of Delphi

Firry answered 15/8, 2011 at 18:46 Comment(16)
+1 I always enjoy reading your questions.Achromic
They certainly can take a long time to write, format, link, etc. And in this case an earlier iteration that included arabic numerals crashed SO. Nice to know that the effort is no unappreciated.Firry
You are on delphi5. Since Delphi2007 you can call with a separate set of formatsettings for all those format functions. Time to step up ?Hypophysis
As a sidenote, we have a couple of delphi5 applications running which uses your so called horrible hack (yes its pretty ugly). Be careful though, threads may bite you and also 3rd party components.Hypophysis
@LU - What good is update? TFormatSettings.DecimalSeparator is still a Char..Homesteader
I don't see the horribleness of your hacky solution (apart from possible threading issues). Str() can possibly be an alternative, it always produces a string with '-' as a negative sign and '.' decimal separator, I think..Homesteader
Str might be worth looking into. i know there are algorithms to convert numbers into strings by repeated division; but i prefer Delphi's TFloatRec, which already stores the digits as an array[0..20] of Char. But then i just have to deal with the Exponent: Smallint. (Negative: Boolean is a simple matter of prepending a hyphen-minus character in front)Firry
@LU RD: Oh god; i hadn't thought about threading issues. Sertac Akyuz: That's why it was a hack; i would be applying a global fix to a local problem (blogs.msdn.com/b/oldnewthing/archive/2008/12/11/9193695.aspx). It felt like a hack when i was doing it, because i knew it was in danger of things like this.Firry
are you running on windows 7? there is a bug in windows showing the localizations settings in system center. try to set the current system setting to something - then "ok" and then set it back to your settings.Godroon
@Bernd Ott i know the bug you're referring to; this isn't that. That is when people use GetThreadLocale when they should have been using GetUserDefaultLCID, or simply LOCALE_USER_DEFAULT. There is no lag here between the registry's Locale and LocaleID entries: Windows is returning the correct decimal separator (,,,). But Delphi goes insane if your decimal separator is longer than one character, throws up its hands, and uses a hard-coded ".".Firry
@Sertac Akyuz Another downsize of the hack is people might want to call this function a lot (i.e. processing database results). It's wasteful to set a global, perform an operation, and set it back. Processing 30,000 numbers results in 60,000 wasted operations. And there's still the threading contention - since i do process results from a background thread :(Firry
@Ian, as I mentioned before, in D2007 and up there is a thread safe way to do this using a separate set of formatsettings. I can provide an example if you like.Hypophysis
So does this mean there's a bug in Delphi? If so, it would be worth logging in QC: qc.embarcadero.com/wc/qcmain.aspxJoshi
yes, it should reported to QC!Godroon
"While the LOCALE_SDECIMAL is allowed to be up to three characters" . Note however that setting the LOCALE_SDECIMAL to be more than a single character breaks even Microsofts own handling of the decimal seperator as recently as Office 2007.Lagerkvist
Outlook 2010 also fails to show dates if the date separator is //, and SQL Server Management Studio can no longer design tables if the decimal separator is anything other than a period (e.g. ,). i'd like my software to be properly written at least; dog-food it and it's amazing how fast apps crash.Firry
F
3

Delphi does provide a procedure called FloatToDecimal that converts floating point (e.g. Extended) and Currency values into a useful structure for further formatting. e.g.:

FloatToDecimal(..., 1234567890.1234, ...);

gives you:

TFloatRec
   Digits: array[0..20] of Char = "12345678901234"
   Exponent: SmallInt =           10
   IsNegative: Boolean =          True

Where Exponent gives the number of digits to the left of decimal point.

There are some special cases to be handled:

  • Exponent is zero

       Digits: array[0..20] of Char = "12345678901234"
       Exponent: SmallInt =           0
       IsNegative: Boolean =          True
    

    means there are no digits to the left of the decimal point, e.g. .12345678901234

  • Exponent is negative

       Digits: array[0..20] of Char = "12345678901234"
       Exponent: SmallInt =           -3
       IsNegative: Boolean =          True
    

    means you have to place zeros in between the decimal point and the first digit, e.g. .00012345678901234

  • Exponent is -32768 (NaN, not a number)

       Digits: array[0..20] of Char = ""
       Exponent: SmallInt =           -32768
       IsNegative: Boolean =          False
    

    means the value is Not a Number, e.g. NAN

  • Exponent is 32767 (INF, or -INF)

       Digits: array[0..20] of Char = ""
       Exponent: SmallInt =           32767
       IsNegative: Boolean =          False
    

    means the value is either positive or negative infinity (depending on the IsNegative value), e.g. -INF


We can use FloatToDecimal as a starting point to create a locale-independent string of "pictures codes".

This string can then be passed to appropriate Windows GetNumberFormat or GetCurrencyFormat functions to perform the actual correct localization.

i wrote my own CurrToDecimalString and FloatToDecimalString which convert numbers into the required locale independent format:

class function TGlobalization.CurrToDecimalString(const Value: Currency): string;
var
    digits: string;
    s: string;
    floatRec: TFloatRec;
begin
    FloatToDecimal({var}floatRec, Value, fvCurrency, 0{ignored for currency types}, 9999);

    //convert the array of char into an easy to access string
    digits := PChar(Addr(floatRec.Digits[0]));

    if floatRec.Exponent > 0 then
    begin
        //Check for positive or negative infinity (exponent = 32767)
        if floatRec.Exponent = 32767 then //David Heffernan says that currency can never be infinity. Even though i can't test it, i can at least try to handle it
        begin
            if floatRec.Negative = False then
                Result := 'INF'
            else
                Result := '-INF';
            Exit;
        end;

        {
            digits:    1234567 89
              exponent--------^ 7=7 digits on left of decimal mark
        }
        s := Copy(digits, 1, floatRec.Exponent);

        {
            for the value 10000:
                digits:   "1"
                exponent: 5
            Add enough zero's to digits to pad it out to exponent digits
        }
        if Length(s) < floatRec.Exponent then
            s := s+StringOfChar('0', floatRec.Exponent-Length(s));

        if Length(digits) > floatRec.Exponent then
            s := s+'.'+Copy(digits, floatRec.Exponent+1, 20);
    end
    else if floatRec.Exponent < 0 then
    begin
        //check for NaN (Exponent = -32768)
        if floatRec.Exponent = -32768 then  //David Heffernan says that currency can never be NotANumber. Even though i can't test it, i can at least try to handle it
        begin
            Result := 'NAN';
            Exit;
        end;

        {
            digits:   .000123456789
                         ^---------exponent
        }

        //Add zero, or more, "0"'s to the left
        s := '0.'+StringOfChar('0', -floatRec.Exponent)+digits;
    end
    else
    begin
        {
            Exponent is zero.

            digits:     .123456789
                            ^
        }
        if length(digits) > 0 then
            s := '0.'+digits
        else
            s := '0';
    end;

    if floatRec.Negative then
        s := '-'+s;

    Result := s;
end;

Aside from the edge cases of NAN, INF and -INF, i can now pass these strings to Windows:

class function TGlobalization.GetCurrencyFormat(const DecimalString: WideString; const Locale: LCID): WideString;
var
    cch: Integer;
    ValueStr: WideString;
begin
    Locale
        LOCALE_INVARIANT
        LOCALE_USER_DEFAULT     <--- use this one (windows.pas)
        LOCALE_SYSTEM_DEFAULT
        LOCALE_CUSTOM_DEFAULT       (Vista and later)
        LOCALE_CUSTOM_UI_DEFAULT    (Vista and later)
        LOCALE_CUSTOM_UNSPECIFIED   (Vista and later)
}

    cch := Windows.GetCurrencyFormatW(Locale, 0, PWideChar(DecimalString), nil, nil, 0);
    if cch = 0 then
        RaiseLastWin32Error;

    SetLength(ValueStr, cch);
    cch := Windows.GetCurrencyFormatW(Locale, 0, PWideChar(DecimalString), nil, PWideChar(ValueStr), Length(ValueStr));
    if (cch = 0) then
        RaiseLastWin32Error;

    SetLength(ValueStr, cch-1); //they include the null terminator  /facepalm
    Result := ValueStr;
end;

The FloatToDecimalString and GetNumberFormat implementations are left as an exercise for the reader (since i actually haven't written the float one yet, just the currency - i don't know how i'm going to handle exponential notation).

And Bob's yer uncle; properly localized floats and currencies under Delphi.

i already went through the work of properly localizing Integers, Dates, Times, and Datetimes.

Note: Any code is released into the public domain. No attribution required.

Firry answered 17/8, 2011 at 21:46 Comment(0)
H
2

Ok, this may not be what you want, but it works with D2007 and up. Thread safe and all.

uses Windows,SysUtils;

var
  myGlobalFormatSettings : TFormatSettings;

// Initialize special format settings record
GetLocaleFormatSettings( 0,myGlobalFormatSettings);
myGlobalFormatSettings.DecimalSeparator := '.';


function FloatToLocaleIndependantString(const value: Extended): string;
begin
  Result := FloatToStrF(Value, ffFixed, 
        18, //Precision: "should be 18 or less for values of type Extended"
        9, //Scale 0..18.   Sure...9 digits before decimal mark, 9 digits after. Why not
        myGlobalFormatSettings
  );
end;
Hypophysis answered 16/8, 2011 at 22:24 Comment(2)
i wouldn't be able to use it directly; i'd still have to run it through Window's GetNumberFormat to convert it to a proper string. On top of that i don't have D2007; but i might be able to steal the RTL source - unless it's still in an assembly include file (as D5 is)Firry
You could take a look into FPC source code, a similar function is available in their SysUtils unit.Hypophysis

© 2022 - 2024 — McMap. All rights reserved.