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

Community
  • 1
  • 1
Ian Boyd
  • 246,734
  • 253
  • 869
  • 1,219
  • 5
    +1 I always enjoy reading your questions. – Andreas Rejbrand Aug 15 '11 at 18:52
  • 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. – Ian Boyd Aug 15 '11 at 19:16
  • You are on delphi5. Since Delphi2007 you can call with a separate set of formatsettings for all those format functions. Time to step up ? – LU RD Aug 15 '11 at 20:40
  • 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. – LU RD Aug 15 '11 at 20:59
  • 1
    @LU - What good is update? TFormatSettings.DecimalSeparator is still a Char.. – Sertac Akyuz Aug 15 '11 at 21:34
  • 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.. – Sertac Akyuz Aug 15 '11 at 21:40
  • `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) – Ian Boyd Aug 15 '11 at 21:49
  • @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 (http://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. – Ian Boyd Aug 15 '11 at 21:53
  • 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. – coding Bott Aug 16 '11 at 11:20
  • @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 "`.`". – Ian Boyd Aug 16 '11 at 21:24
  • @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 :( – Ian Boyd Aug 16 '11 at 21:25
  • @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. – LU RD Aug 16 '11 at 21:46
  • So does this mean there's a bug in Delphi? If so, it would be worth logging in QC: http://qc.embarcadero.com/wc/qcmain.aspx – David Aug 17 '11 at 05:16
  • yes, it should reported to QC! – coding Bott Aug 17 '11 at 11:44
  • "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. – HMcG Aug 17 '11 at 11:58
  • 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. – Ian Boyd Aug 17 '11 at 18:37

2 Answers2

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.

Ian Boyd
  • 246,734
  • 253
  • 869
  • 1,219
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;
LU RD
  • 34,438
  • 5
  • 88
  • 296
  • 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) – Ian Boyd Aug 16 '11 at 22:49
  • You could take a look into FPC source code, a similar function is available in their SysUtils unit. – LU RD Aug 16 '11 at 23:05