Localization of projects on .NET with a function interpreter

Prologue


To begin with, over the years of my work as a programmer, I have repeatedly encountered the task of introducing localization in one form or another into a project, but usually these were solutions based on a loaded dictionary with key-value pairs. This approach is justified for small projects, but has a number of significant drawbacks:

  1. The complexity of implementing an existing project.
  2. Lack of formatting tools for localized messages (with the exception of the standard string.Format).
  3. Inability to embed culturally dependent functions. For example, a typical task — substitution of the desired form of a word depending on the value of a number — cannot be resolved by dictionaries alone.


After analyzing these problems, I came to the conclusion that it was necessary to create my own library for localizing projects, which would be free from the drawbacks listed above. In this article, I’ll talk about how it works with C # code examples.

Library Composition


Link to the SourceForge project: https://sourceforge.net/projects/open-genesis/?source=navbar

Example: LocalizationViewer

The assembly includes the following projects:

  • Genesis.Localization is the main localization library.
  • Ru - implementation of Russian localization (example).
  • En - implementation of English localization (example).
  • LocalizationViewer - a program to demonstrate the capabilities of the library with the ability to edit localizations.

    image



Basic principles


image

Localization manager

The library is built on the basis of plugins and works as follows: when the application starts, a localization manager ( LocalizationManager ) is created, which shows the path to the directory where it will search for available localization packages ( LocalizationPackage ), each of which is responsible for a certain culture (Russian localization package, English, etc.). After that, a command is given to search and download the descriptors of all packages, the entire initialization code looks something like this:

// инициализация менеджера локализаций
LocalizationManager = new LocalizationManager();
LocalizationManager.BasePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Localization");
LocalizationManager.Initialize();
try
{
    LocalizationManager.DetectAllLocalizations();
}
catch (LocalizationException ex)
{
    MessageBox.Show(ex.Message, "Ошибка локализации", MessageBoxButtons.OK, MessageBoxIcon.Error);
    return;
}

If everything went smoothly, the manager will display a list of available localizations in the form of their brief descriptions (descriptors, LocalizationDescriptor ). These descriptors do not contain any logic in themselves, but serve only as a description of a package that can be downloaded and started to be used in the program.

A list of all localizations can be obtained from the manager:

manager.Localizations

For example, we wanted to connect Russian localization, for this we need to load it directly into the manager:

LocalizationPackage package = manager.Load("ru");

After loading, you can work with localization - get lines, resources, etc. from it, and if it is no longer needed, you can unload it:

manager.Unload("ru");

Important! You can load and unload an unlimited number of localizations, as all of them are created in own domains (AppDomain).

Localization package

Each localization is a set of files in a separate directory, the root for all is the one that was selected when loading the localization manager. In the example above, this will be the [ProjectDir] \ Localization directory , and the localization packages will be directly located in the [ProjectDir] \ Localization \ ru , [ProjectDir] \ Localization \ en directories , etc. ...

Each standard package must contain the following files :

image

  • localization.info - xml file with a brief description of the package, these files are initially downloaded by the localization manager.

    An example for Russian localization:

    Русскийru

    As you can see, there are only two fields, maybe later new fields will be added to identify a particular package.
  • flag.png - an image symbolizing localization. In my examples, these are state flags with a size of 16x16 pixels.
  • strings.xml - xml file containing localized strings. When overriding the logic of a package, you can create your own source of strings, for example, a binary or a database.
  • package.dll - the executable module of the package is a small library in which a class inherited from LocalizationPackage must be present .

    Example of executable code for Russian localization:

    using System;
    using Genesis.Localization;
    namespace Ru
    {
        public class Package : LocalizationPackage
        {
            protected override object Plural(int count, params object[] forms)
            {
                int m10 = count % 10;
                int m100 = count % 100;
                if (m10 == 1 && m100 != 11)
                {
                    return forms[0];
                }
                else if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20))
                {
                    return forms[1];
                }
                else
                {
                    return forms[2];
                }
            }
        }
    }
    

    An explanation will be given below of what the Plural method is.


Using localization packages

So, we created a localization manager and loaded the translation package into it. Now it can be used in the program in three ways:

  1. Getting a specific string by its key (classic method). The key can be a string or a number of type Int32.

    Usage example:
    LocalizationPackage package = manager.Load(culture);
    string strByName = package["name"];
    string strByID = package[150];
    

  2. Getting a formatted string with passing arguments. This is the method for which the library was created.

    Usage example:
    LocalizationPackage package = manager.Load(culture);
    string formattedString = package["name", arg1, args2, ...];
    

    Any objects can be used as arguments. More details about this method will be described below.

  3. Getting the path to a localized resource. To do this, use the GetResourceFilePath (string filename) method to get the path to an arbitrary file in the localization directory or the GetImage (string filename) method to load the image from there.


String interpreter


The strength of the library lies in its string interpreter. What is he like?
In short, this is a set of instructions included in localized strings with which you can adapt the translation to a particular culture.

The string interpreter is called by the method described above for obtaining a string with the given arguments (in case of normal key handling, the localized string is returned in a “pure” form) or with the special GetFormattedString method (string format, params object [] args), which works in exactly the same way, but at the same time an arbitrary format string is passed as the first argument.

Now more about these instructions. There are two of them:

  1. The inclusion of an argument in a string.

    Format Instructions:
    %index%
    

    Result: embedding the argument number index in the string

    Example of use:
    package.GetFormattedString("%1% = %0%%%", 80, "КПД");
    

    Result:
    КПД = 80%
    

    Please note that the % character , being a service character , must be escaped by another character that is the same as in this example.

  2. Enabling features

    Format instructions:
    %Func(arg1, arg2, ..., argN)%
    


    The arguments can be numbers , double-quoted strings (the quotes themselves are escaped, like%, by double repetition), string arguments by their number (% index), or calls to other functions .

    Usage example:
    package.GetFormattedString("Игрок %1% наносит по вам %Upper(Random(\"сильный\", \"сокрушительный\", \"мощный\"))% удар, отнимая %0% %Plural(%0, \"единицу\", \"единицы\", \"единиц\")% здоровья.", 55, "MegaDeath2000");
    

    Result:
    Игрок MegaDeath2000 наносит по вам СОКРУШИТЕЛЬНЫЙ удар, отнимая 55 единиц здоровья.
    


Built-in Features and Integration


The LocalizationPackage class has several built-in “standard” functions, part of which was used in the example above:

  • Plural (int, var1, var2, ..., varN) - embedding the word form depending on the number, this method is unique for each culture and must be redefined. In particular, in the Russian language there are three forms of numbers (for example: “1 unit”, “2 units”, “8 units”).
  • Random (var1, var2, ..., varN) - selection of a random value among the given ones.
  • Upper (string) - cast to uppercase.
  • Lower (string) - cast to lower case.
  • UpperF (string) - casting to the upper case only the first letter ("word" => "Word").
  • LowerF (string) - cast to the lower case only the first letter.

If you need to add new features, there are two ways to do this.

  1. In the redefined class of the package, you can declare new functions and mark them with the [Function] attribute, then they will be automatically included in the interpreter for a specific localization. Built-in functions are defined in this way, for example, the Plural and Random functions look like this:

    [Function("P")]
    protected abstract object Plural(int count, params object[] forms);
    [Function]
    protected virtual object Random(params object[] variants)
    {
        if (variants.Length == 0)
        {
            return null;
        }
        else
        {
            return variants[_rnd.Next(variants.Length)];
        }
    }
    

    Please note that it is permissible for a function to specify a list of its aliases (for short record), for example, Plural can be called both through the main name ( Plural ) and through an alias ( P ), while the case in the names of the functions does not matter.

  2. Integration of own functions, for this, the InjectFormatterFunction method is used , an example of use:

    var package = LocalizationManager.Load("ru");
    package.InjectFormatterFunction(new Func((a, b) => Math.Min(a, b)), "Min");
    package.InjectFormatterFunction(new Func((a, b) => Math.Max(a, b)), "Max");
    package.GetFormattedString("%min(%0, max(%1, %2))%", 10, 8, 5);
    

    Result:
    8
    

    A method (MethodInfo) or a delegate can be passed as an argument to an InjectFormatterFunction (delegates are passed in the example above).

Additional features


In addition to the basic functions, the library provides two more additions.

Debug mode

The Debug version of the library includes the ability not only to obtain localized strings in the ways described above, but also to write them directly:

var package = LocalizationManager.Load("ru");
package["New Key"] = "Новое значение";
package.Save();

In this case, a new localized string will be created with the specified key and value (or the existing one will be overwritten), and the package itself will be saved to disk. Also, in debug mode, when trying to read a line with a missing key, an empty value will be returned, but a new record will be created. This is convenient at the initial stage of development - we do not need to worry about filling out the dictionary - it will itself be replenished with empty values, which we will then fill with data.

In the release, recording functions are not available, which is logical - an industrial program should not be able to replenish its localization dictionary.

Mappings

This is our dessert. Purpose - quick localization of forms, controls and other complex objects.
This function is used in the LocalizationViewer demo project .

Here is an excerpt from the description of the main form:

[LocalizableClass("Text", "CAPTION")]
public partial class frmMain : Form
{
        ...
        [LocalizableClass]
        private System.Windows.Forms.ToolStripButton cmdExit;
        [LocalizableClass]
        private System.Windows.Forms.ToolStripButton cmdSave;
        [LocalizableClass]
        private System.Windows.Forms.ToolStripLabel lblSearch;
        ...
        /// 
        /// применяем локализацию
        /// 
        private void Localize()
        {
            LocalizationMapper mapper = new LocalizationMapper();
            mapper.Current = manager["ru"];
            mapper.Localize(this);
        }
        ...
}

LocalizationMapper , allows you to localize any object passed to it in the Localize function using the [Localizable] and [LocalizableClass] attributes on the fields and properties of the localized object (in this case, forms). For example, the [LocalizableClass] attribute without parameters means that you need to localize the default property (Text), and an automatic key of the form will be used... For the Text field of the cmdExit button, the key will be like this:

LocalizationViewer.frmMain.cmdExit_Text


Conclusion


The library will soon be tested in one of my projects, so most likely there will be some improvements, mainly aimed at expanding the basic functionality of the packages. Stay tuned for updates on SourceForge and write your comments and thoughts on the further development of the library.

PS


You might say that I am reinventing the wheel. Even so, but inventing bicycles is my hobby ...
In addition, it is much more interesting and useful in terms of self-improvement in programming.
For the same reason, there will be no references to literature or other sources of information - everything was written from scratch.

My other publications



  1. Filling text templates with model-based data. .NET implementation using dynamic bytecode (IL) functions

Also popular now: