Culture-specific strings pluralization in .NET

Posted by Dmytro Shteflyuk on under ASP.NET

Not so long ago I faced a problem of displaying count of some entities in singular or plural form (“No comments” or “less than a minute ago”, “1 comment” or “a minute ago”, “2 comments” or “2 minutes ago”). It’s very easy for English (there are three variants only), but I’m working on application that should be localized for several cultures. For example, in Russian we have at least 4 forms (“Нет комментариев”, “1 комментарий”, “2 комментария”, “5 комментариев”) and not so obvious rules for plural forms (“11 комментариев”, “111 комментариев”, but “21 комментарий”). I don’t speak any other language, but I think that several of them might be even more complicated than Russian. Here you can find my thoughts about such strings localization.

ASP.NET (and as I know, Java, Ruby, etc) does not have built-in functionality for numeric strings localization. Yes, I know about Rubish pluralize, but it does not working for Russian, and I suspect for some other languages too. So I need to implement pluralization, desirable using ASP.NET resources, with simplest interface as possible. Adding new languages should be easy enough.

So, here is my idea. We will define several strings in our resources (Comments0, Comments1, Comments2, etc). We have some interface, for example IResourceIndexer. Interface has single method, which should return resource index by entities number. We have to implement this interface for different languages (English, Russian, etc). Then we need to create factory, which would return culture-specific IResourceIndexer. And latest step is to create static class with helper methods, which will return strings using number of entities. But seeing is believing. Let’s look at the code.

Here is interface. As you can see, it’s very simple — just single method, which returns resource index.

1
2
3
4
5
6
7
namespace App_Code
{
    public interface IResourceIndexer
    {
        int GetResourceIndex(long count);
    }
}

[lang_en]
Let’s implement indexers for English and Russian languages:
[/lang_en]
[lang_ru]
Давайте реализуем индексаторы для английского и русского языков:
[/lang_ru]

1
2
3
4
5
6
7
8
9
10
11
12
namespace App_Code
{
    public class EnglishResourceIndexer : IResourceIndexer
    {
        public int GetResourceIndex(long count)
        {
            if (count == 0) return 0;
            if (count == 1) return 1;
            return 2;
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace App_Code
{
    public class RussianResourceIndexer : IResourceIndexer
    {
        public int GetResourceIndex(long count)
        {
            if (count == 0) return 0;
            if (count == 1) return 1;

            int twoDigits = (int) count % 100;
            if (twoDigits > 10 && twoDigits < 20) return 3;

            int lastDigit = (int) (count % 10);
            if (lastDigit == 1) return 4;
            if (lastDigit > 1 && lastDigit < 5) return 2;
            return 3;
        }
    }
}

As you can see, Russian is more difficult than English :-) But it still easy enough to implement. Hope, for other languages we could do something like this without any problems. Please note, that I have added different resource indexes for 1 and 21 in Russian. Usually this is not needed, but I want to have ability to use string “минуту назад” for 1 and “21 минуту назад” for 21.

Now we need to define factory to get indexer using current culture (which could be accessed through Thread.CurrentThread.CurrentUICulture property).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System.Collections.Generic;
using System.Globalization;
using System.Threading;

namespace App_Code
{
    public static class NumericResourceFactory
    {
        static NumericResourceFactory()
        {
            _resourceIndexerCache = new Dictionary<string, IResourceIndexer>();
        }

        public static IResourceIndexer GetResourceIndexer()
        {
            CultureInfo culture = Thread.CurrentThread.CurrentUICulture;
            return GetResourceIndexer(culture);
        }

        private static IResourceIndexer GetResourceIndexer(CultureInfo culture)
        {
            string id = culture.TwoLetterISOLanguageName;
            if (!_resourceIndexerCache.ContainsKey(id))
            {
                switch(id)
                {
                    case "ru":
                        _resourceIndexerCache[id] = new RussianResourceIndexer();
                        break;
                    default:
                        _resourceIndexerCache[id] = new EnglishResourceIndexer();
                        break;
                }
            }
            return _resourceIndexerCache[id];
        }

        private static Dictionary<string, IResourceIndexer> _resourceIndexerCache;
    }
}

As you can see, I’m detecting indexer using two letters ISO code. Of course, you can do more complex processing here. Please note, that English is default language in my application, you might need to change cases order. I’m using indexers cache in this code to avoid factory performance hit.

Almost finished. Now we need to define resources and implement helper class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace App_Code
{
    public static class ResourceStrings
    {
        public static string GetCommentsString(int comments)
        {
            IResourceIndexer indexer = NumericResourceFactory.GetResourceIndexer();
            string format = GetResourceString("Comments" + indexer.GetResourceIndex(comments));
            return String.Format(format, comments);
        }

        private static string GetResourceString(string id)
        {
            return Resources.NumericResources.ResourceManager.GetString(id,
                Resources.NumericResources.Culture);
        }
    }
}

And resources:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Comments0" xml:space="preserve">
    <value>No comments</value>
    <comment>0 comments</comment>
  </data>
  <data name="Comments1" xml:space="preserve">
    <value>1 comment</value>
    <comment>1 comment</comment>
  </data>
  <data name="Comments2" xml:space="preserve">
    <value>{0} comments</value>
    <comment>2 comments (and more)</comment>
  </data>
  <data name="Comments3" xml:space="preserve">
    <value />
    <comment>not used in English</comment>
  </data>
  <data name="Comments4" xml:space="preserve">
    <value />
    <comment>not used in English</comment>
  </data>
</root>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Comments0" xml:space="preserve">
    <value>Нет комментариев</value>
    <comment>0 комментариев</comment>
  </data>
  <data name="Comments1" xml:space="preserve">
    <value>{0} комментарий</value>
    <comment>1 комментарий</comment>
  </data>
  <data name="Comments2" xml:space="preserve">
    <value>{0} комментария</value>
    <comment>2-4 комментария</comment>
  </data>
  <data name="Comments3" xml:space="preserve">
    <value>{0} комментариев</value>
    <comment>5-9 комментариев</comment>
  </data>
  <data name="Comments4" xml:space="preserve">
    <value>{0} комментарий</value>
    <comment>21 комментарий</comment>
  </data>
</root>

Please note, that I have 5 resource strings in English strings file, in spite of only 3 really need. That’s because English is my default language, and to build my satellite assembly with Russian resources, I must have same string resources in both resource files.

Little more about usage. First you should initialize thread culture. In ASP.NET applications you should override method InitializeCulture and set Thread.CurrentThread.CurrentCulture and Thread.CurrentThread.CurrentUICulture properties:

1
2
3
4
5
6
7
8
9
10
11
12
protected override void InitializeCulture()
{
    CultureInfo culture;
    if (Request.UserLanguages != null && Request.UserLanguages.Length > 0)
        culture = CultureInfo.CreateSpecificCulture(Request.UserLanguages[0]);
    else
        culture = CultureInfo.CreateSpecificCulture("");

    Thread.CurrentThread.CurrentCulture = culture;
    Thread.CurrentThread.CurrentUICulture = culture;
    base.InitializeCulture();
}

In this case browser culture would be used (in Firefox — Tools/Options/Advanced/Languages -> Choose, add Russian or English as first language; in Internet Explorer it could be done here — Tools/Internet Options/Languages.) Now you can write something like this in .aspx file:

1
2
3
4
<%@ Import namespace="App_Code" %>
<asp:Label runat="server">
    <%= ResourceStrings.GetCommentsString(20) %>
</asp:Label>

Full example project could be downloaded here. Have any comments?

5 Responses to this entry

Subscribe to comments with RSS

Mihail
said on August 29th, 2007 at 10:24 · Permalink

Спасибо за отличный пример!

Mihail
said on August 29th, 2007 at 10:37 · Permalink

Кстати, код не компилируется:

Compilation Error
Description: An error occurred during the compilation of a resource required to service this request. Please review the following specific error details and modify your source code appropriately.

Compiler Error Message: CS0246: The type or namespace name ‘App_Code’ could not be found (are you missing a using directive or an assembly reference?)

said on August 29th, 2007 at 12:24 · Permalink

2Mihail: Странно, но у меня код компилируется нормально :-) В Visual Studio 2005 нужно открыть проект как File/Open/Web Site…

pechkinator
said on September 7th, 2007 at 17:15 · Permalink

есть плагин для Ruby On Rails – Globalize. Там эта проблема решина так. В таблице с языками у каждого языка есть так называемая pluralization-строка. Например для русского:

1
c%10==1 && c%100!=11 ? 1 : c%10>=2 && c%10<=4 && (c%100<10 || c%100>=20) ? 2 : 3

, для английского:

1
c == 1 ? 1 : 2

, для ирландского:

1
c==1 ? 1 : c==2 ? 2 : 3

и т.д. Затем переводы находятся по ключу и количеству, где количество это на самом деле не количество, а число которое возвращает pluralization-строка при выполнении, если c это количество объектов.

Использовать в коде это очень просто, напр.

1
"%d COMMENT".t(@comments.count)

%d подмениться на число, а вместо COMMENT подставиться слово “комментарий” в нужной форме.

Comments are closed

Comments for this entry are closed for a while. If you have anything to say – use a contact form. Thank you for your patience.