Tuesday, February 07, 2012   
 Search   
 

FoxTalk.gif

Issue Date: FoxTalk January 2004

Can You Speak My Language?

Bogdan Zamfir

In today's world, many companies are expanding overseas, and they usually have employees who speak different languages. Even if most employees who work for such companies can use a common language besides their own mother language, most of them are still more proficient in their own native one. Today, modern programming environments offer many features that allow software applications to be developed with multi-language support. In this article, I explores a method for handling on-the-fly string translation throughout an interface in Visual FoxPro applications.

Internationalization of an application has several components. One includes using locale settings for numbers, date and time, and several other options. Another, the interface, requires string resources that can be built into the EXE, to allow translation of the interface. However, all those issues are "hard-coded" into the EXE at compile time. If an application needs to be deployed in different languages, a different EXE needs to be compiled and deployed.

An elegant solution is to have a single EXE with all "languages" built into it, which can be switched dynamically at runtime based on a setting. This could be a real user-friendly multi-language application. In this article, I'll describe a solution to allow dynamic translation of Visual FoxPro forms and all of their controls.

A little background about controls in VFP
In VFP, all controls belong to two main categories: container and non-container type controls. Container type controls have the ability to "host" other controls on them, regardless of whether they're simple controls or container controls. The controls hierarchy, started from a container control, can be nested on an unlimited number of levels.

Container type controls are Form, FormSet, Container, Pageframe, Page, OptionGroup, CommandGroup, Grid, and Column. Some of them can host any kind of child controls, while others can host only a special class and its subclasses (like Pageframe, Grid, FormSet, and so on). In this article, the term Container will be used to refer to any container type control mentioned earlier.

In VB, all controls are children of the form they're hosted on, regardless of whether in the designer they sit directly on the form or sit on a container type control. By comparison, in VFP controls are members of the Controls collection of the container they sit on. Container controls can be nested on as many levels as necessary.

Finally, remember that controls are actually organized as a tree, having a Form or FormSet as the tree's root. To access a control starting from the root Form or FormSet, you have to use the full path to that control. For example:

Thisform.PageFrame1.Page2.Container1.Textbox1

Implementation considerations
In order to provide dynamic translation, I created a Translation class. This class, which I'll describe shortly, uses a table to retrieve localized strings needed to translate the interface. The table is called AppTrans.dbf and has the structure shown in Table 1.

Table 1. Structure of the AppTrans.dbf table.

Field name

Type and size

LangID

C (2)

ObjName

C (50)

PathName

C (120)

Caption

C (120)

StatBar

C (80)

ToolTip

C (80)

Since translation of strings is usually done at development time, it's recommended to include the table in the EXE. To do this, right-click on the table name in the Project Manager and select Include. You can use any name you prefer for the translation table, but you have to be sure to set the name in the cTranslationTable property of the translation object. Here are descriptions of the fields in the AppTrans table:

LangID�This field stores the LanguageID for the record. LanguageID can be any string defined by the developer, but I recommend using an ISO code string for language names. For example, EN � English, DE � German, FR � French, RO � Romanian. See http://ftp.ics.uci.edu/pub/ietf/http/related/iso639.txt for more details.

ObjName�This is the object name for the container. It's the same as the NAME property for the object. Important: This field must be filled using capital letters only, to take advantage of indexes when retrieving data for controls to be translated.

PathName�This is the path name to access the control to translate. This field contains the full path from the container to the target control except the container name. For the control shown in the previous section, the PathName to translate TextBox1 is PageFrame1.Page2.Container1.Textbox1. If the PathName field is empty, it means that the record is for the container object itself.

Caption�This is the translated caption for the target control. It's mapped to the Caption property of the target control.

StatBarThis is the translated status bar text for the target control. It's mapped to the StatusBar property of the target control.

ToolTipThis is the translated ToolTip text for the target control. It's mapped to the ToolTip property of the target control.

You can translate custom controls created by subclassing any native VFP class that doesn't already have those three translatable properties (Caption, StatusBar, and ToolTip). Then you can simply add any or all of those three properties as custom properties, and they'll be available for translation. For example, you can create a class based on a container, add a label control called lblCaption, add a custom property called Caption to the container type class with an Assign method, and, in the Caption_Assign method, add the following code:

* MyContainerClass.Caption_Assign method
lParameters cNewCaption
this.Caption = cNewCaption
this.lblCaption.Caption = cNewCaption

Here are a few tips on naming containers:

� For forms, the NAME property must be changed from the default one provided by VFP. This is necessary because the translation code uses the form's NAME property to find all controls to translate on that form, and not the form's file name, as the VFP DO FORM statement does to instantiate the form. I recommend that you use the same name as the form's file name. This way, users can just look at the form's file name in the Project Manager, open the translation table, and easily find the records related to that form.

� Now for other container type controls. Suppose I use a generic class across the application on several forms. For example, let's take a container control used for Customer's details that contains fields for first/middle/last name, address, phone, e-mail, and so on. I name this class objCustomerDetails. When dropped on a form, VFP sets the object's name to objCustomerDetails1, objCustomerDetails2, and so forth. If I want to translate the class in every place it's used, with a single set of records in AppTrans.dbf, it's necessary to use the same name for the object in all places. I recommend using the same name as the class name itself. This way, you can look for the class name in the Project Manager's Classes tab, and easily find appropriate records in the translation table.

Translation class - bzTranslation
In order to implement translation code with any existing application, I wanted to make it as self-contained as possible. Doing this, I can very easily integrate it into any existing framework or application.

The Translation class takes care of the whole dynamic translation process. It's based on the Custom class and has two public custom properties and two public methods (see Table 2 and Table 3).

Table 2. Custom properties of the bzTranslation class.

Property name

Type

cLangID

String

cTranslationTable

String

Table 3. Custom methods of the bzTranslation class.

Method name

Parameter

TranslateInterface

toObject (reference to a VFP container type object)

TranslateInterfaceAll

toObject (reference to a VFP container type control)

Here are descriptions of the custom properties and methods of the bzTranslation class:

cLangID�This is the current language's ID for the application. It should match one of the LangID values from AppTrans.dbf.

cTranslationTable�This is the translation's table name. The default value is Include\AppTrans.dbf. This means the translation table is called AppTrans.dbf and it's stored in subfolder Include of the application's root folder. Any path, relative from the default folder or absolute, can be used.

TranslateInterface�This method does the translation. It receives a parameter, toObject�a VFP container type control�and can translate all of its child controls.

TranslateInterfaceAll�This method handles recursive calls on all containers hosted on toObject, if those child containers require or can handle their own translation.

TranslateInterface is the method that does the translation. Here's its code (irrelevant code was stripped off, for clarity):

* bzTranslation class - TranslateInterface
LPARAMETERS toObject
lcSelCursor = JUSTFNAME(this.cTranslationTable)
IF not USED(lcSelCursor)
lcSelCursor = this.cTranslationTable
lcSelAlias = JUSTSTEM(lcSelCursor)
ELSE
lcSelAlias = lcSelCursor
endif
* if translation table is already open, select from 
* alias it was open into, otherwise select using 
* tablename 
* VFP uses the stem as alias when SELECT from a table
* if that alias is not already used
lcSelect="SELECT * from ('" lcSelCursor "') "  ;
" where ObjName==["   PADR(UPPE(toObject.name),50)  ;
"] and LangID = ["   upper(this.clanged)   "]"  ;
" into cursor __Translate"
* bypass any possible error
this.lSimpleErrorManagement = .t.
&lcSelect
this.lSimpleErrorManagement = .f.

Now the __Translate cursor contains records for all controls that need to be translated on the current container.

The TranslateInterface method is error-tolerant. That is, it allows the AppTrans table to have some invalid paths or control names. For example, suppose a control was renamed or removed from the container, or perhaps its name/path was mistyped. Regardless, the app will continue to work with other controls. This is important because there's no way to validate the AppTrans table against application objects, and it's better to let a control function, untranslated, as long as the application can still be used, instead of raising a runtime error. This behavior is accomplished with the help of the lSimpleErrorManagement property. When it's .T., any error will be trapped, and the error number will be returned in the nErrorNumber property. Those two properties are protected.

The following code scans the __Translate cursor, and for each record it tries to translate the appropriate control's properties.

* �.
this.lsimpleerrormanagement = .t.
SELECT __translate
SCAN
* empty path: the record is for container itself
IF EMPTY(pathname) then
IF not EMPTY(CAPTION) THEN
toObject.Caption = ALLTRIM(CAPTION)
ENDIF
ELSE
lcControl = "toObject."   ALLTRIM(PathName)   "."
IF not empty(caption) then
lcCmd = lcControl   "Caption=[" ;ALLTRIM(caption)   "]"
&lcCmd
ENDIF
IF not empty(StatBar) then
lcCmd = lcControl   "StatusBarText=[" ;ALLTRIM(StatBar)   "]"
&lcCmd
ENDIF
IF not empty(ToolTip) then
lcCmd = lcControl   "ToolTipText=[" ;ALLTRIM(Tooltip)   "]"
&lcCmd
endif
ENDIF
ENDSCAN
this.lsimpleerrormanagement = .f.

TranslateInterfaceAll is a wrapper method around TranslateInterface, which allows translation not only for container objects, but all child controls recursively that are Containers and require their own translation. An example of this case is the bzContactInfo class, which is hosted on the CustView.SCX form (it's available in the accompanying Download). The CustView.SCX form has its own translation, for CustomerID and Company name labels, while the bzContactInfo class has its own translation. This behavior preserves OOP encapsulation, because the translation belongs to the class itself.

The translation code is shown here:

* bzTranslation class - TranslateInterfaceAll
LPARAMETERS toObject
* �
* proceed only if toObject is Container type class
IF INLIST(UPPER(toObject.BaseClass), ;
"FORM", "FORMSET", "CONTAINER", "PAGEFRAME",;
"PAGE", "OPTIONGROUP", "COMMANDGROUP", "GRID",;
"COLUMN")
* translate the object itself
IF NOT this.translateinterface(toObject) then
RETURN .f.
ENDIF
* I use the Objects collection because not 
* all container type classes have a Controls 
* collection (like Grid). But all of them have 
* an Objects collection
If pemstatus(toObject, "Objects", 5) then
FOR EACH oObj IN toObject.Objects
IF INLIST(UPPER(oObj.BaseClass), ;
"FORM", "CONTAINER", "PAGEFRAME", "PAGE", ;
"OPTIONGROUP", "COMMANDGROUP", "GRID", ;
"COLUMN")
* if child is container type, 
* call TranslateInterfaceAll recursively on it
IF NOT THIS.TranslateInterfaceAll(oObj) then
RETURN .f.
ENDIF
ENDIF
NEXT
ENDIF
ENDIF
* �
RETURN .T.

Making the app "dynamic translation" aware
To make the application "dynamic translation" aware, I need to have an instance of the bzTranslation class available to any container type object across the application. It's possible to modify the base class library for all containers in order to create a bzTranslation object on each of them in a custom property. But this approach requires several changes to the base classes. Also, if I store the application's LangID setting in a globally accessible variable, when it's changed I need to update it on every bzTranslation class of every container, in order to make them translate correctly.

A second approach, and the one I use, is to create the bzTranslation object as a member of a global application class. I have the application class as a subclass of the Container VFP control, and I drop onto it the bzTranslation class to create an instance and set its NAME property to "bzTranslation" (the default object name set by VFP was "bzTranslation1").

Then I create a method of the application class called TranslateInterface. It's a wrapper around bzTranslation.TranslateInterface(). The code for this method is shown here:

* global application class - TranslateInterface
lPara toObject
return ;
this.bzTranslation.TranslateInterface(toObject)

Now it's time to make my containers "dynamic translation" aware. I have to handle two different cases:

� Translating the container at creation.

� Changing translation for modeless forms and for containers created on modeless forms, when they already exist, if the global application's setting was changed.

Suppose I have a standard naming convention for my global application object, and suppose it's called goApp. All I have to do is change the Init method of my base Form class (and other container type base classes) and add the following code:

* container type control's Init method
IF NOT DODEFAULT()
RETURN .f.
ENDIF
* You should replace goApp below with a 
* reference to your global application object
* If you have a #DEFINE that refers to your 
* global application object, you can use it too
IF TYPE("goApp") = "O" 
IF PEMSTATUS(goApp, "TranslateInterface",5) then
RETURN goApp.TranslateInterface(this)
ENDIFEndif

With this code, I handle the first translation case mentioned earlier. When the INIT event for an object is raised, all child controls are already created, so they're available to the object passed to the TranslateInterface method.

In the accompanying Download, you can see this code both in bzForm, which is the base class for the demo application, and in the bzContactInfo class, which is used to demonstrate how to translate a user-defined class based on other container type controls. The bzContactInfo object on the CustView form is an instance of the bzContactInfo class. On the CustView form, only the form's caption, lblCust_ID, and lblCaption controls need to be translated. The bzContactInfo class is translated by itself. If I drop it on the other form, I just have to set the NAME property to bzContactInfo, and translation will work.

When the CustView form is instantiated, first the INIT event for the bzContactInfo object occurs, which causes this container to self-translate, and then the INIT event for the form occurs, which causes the form to translate itself (the rest of the objects).

The second case requires translation of all open forms, and all containers they host. For this, I use the TranslateInterfaceAll method, which performs recursive translation on all child objects hosted on a form. I add a new method to the global application object, called TranslateInterfaceAll. The code for this method is shown here:

* global application class - TranslateInterfaceAll
LOCAL i as Integer
LOCAL lSuccess as Boolean
lSuccess = .t.
FOR i = 1 TO _screen.formCount
lSuccess = lsuccess and ;
this.bzTranslation.TranslateInterfaceAll(;
_screen.forms(i))
NEXT 
RETURN lSuccess

If your global application object has a different method to store references to all open forms, you should change the code accordingly.

In the accompanying Download, there's a demo application for this class. It uses the demo database provided by VFP as test data.

Overwriting default translations
Using the bzTranslation object, it's possible, as I showed earlier, to allow dynamic translation for every class subclassed from a container type control. An example of this case is the bzContactInfo class, which is hosted on the CustView.SCX form (available in the Download). The CustView.SCX form has its own translation, for CustomerID and Company name labels, while the bzContactInfo class has its own translation. This behavior preserves OOP encapsulation, where the translation belongs to the class itself.

However, for some instances of bzContactInfo, I might need to overwrite the default translation for some members. I can accomplish this by using the full path for the controls, started from the form or other container control that bzContactInfo is hosted on.

For example, you could have a form called AnotherContactInfo, with a Pageframe control named pfCustomer and two pages, Page1 and Page2. On Page1 there's an instance of bzContactInfo, and on this form you want to translate its lblAddress (the label control for the Address field, as shown in Figure 1) to Home Address instead of just Address.

Figure 1

Can you speak my language Figure 1

To overwrite the default translation for this label on the AnotherContactInfo form, you have to add a record in the AppTrans.dbf table with the following values:

DIR>

ObjName - ANOTHERCONTACTINFO

PathName - pfCustomer.Page1.bzContactInfo.lblAddress

Caption - Home address

 

A small bonus
Sometimes it's necessary to dynamically add some extra information to a form's title bar. Form CustView (available in the Download) shows the customer's name in the title bar beside its default caption. If I need to translate the form's caption only when instantiated, there's no problem. I have to add some code in the form's INIT event, which executes after TranslateInterface. This code finds the record for ClientID passed as a parameter, and can change the form's caption accordingly:

*- CustView.Init
LPARAMETERS nCustID
Local lFound
IF NOT DODEFAULT()  && TranslateInterface was called here
RETURN .f.
ENDIF
lFound = indexseek(nCustID,.t.,"Customer","cust_id")
IF NOT lFound
RETURN .f.
ENDIF 
*-- (3*)
Thisform.Caption = thisform.Caption   " - " ;ALLTRIM(customer.company)

But what do I do if the language setting is changed when the form is already open and shows the customer's data? The form's caption will be translated, but it will lose the customer's Company name. So I need to use a different approach.

In the bzDemoTranslation library I created a class called bzFormCaption, based on the Control VFP class. It's used to translate the form's caption to allow adding some extra information that must be kept in the form's title bar regardless of the translation.

The class has two public properties (see Table 4) with corresponding Assign methods and a protected method, UpdateCaption. Also, its Visible property is set to .F., to keep it hidden.

Table 4. Properties for the bzFormCaption class.

Name

Type

Description

Caption

String

Default caption used for the form. This can be translated.

CExtraCaption

String

This is the part of the form's caption that doesn't change during translation.

The code for this class is shown here:

* -- PROCEDURE caption_assign
LPARAMETERS vNewVal
THIS.Caption = m.vNewVal
this.UpdateCaption()
* -- PROCEDURE cextracaption_assign
LPARAMETERS vNewVal
THIS.cExtraCaption = m.vNewVal
this.updatecaption()
* -- PROTECTED PROCEDURE updatecaption
IF TYPE("thisform") = "O"
thisform.Caption = this.caption   ;
IIF(EMPTY(this.cextracaption), "", " - " ;ALLTRIM(this.cextracaption))
ENDIF 
ENDPROC

Now I replace the line marked with (3*) in the CustView Init method with this:

thisform.bzformheader.cextracaption = ;
ALLTRIM(customer.company)

Demo application
The demo application consists of three forms: the main form (see Figure 2), which allows selecting the interface language, and two forms to open the contacts table in browse mode (Figure 3) and in form mode (Figure 4).

Figure 2

Can you speak my language Figure 2

Figure 3

Can you speak my language Figure 3

Figure 4

Can you speak my language Figure 4

After you select the default language, click on the Browse Customers button. This will open the form shown in Figure 3. This form is open in multi-instance mode. Select a customer in the grid, and then click on the Client Details button to open the Customer Details form (shown in Figure 4). This form can also be opened in multi-instance mode.

If you change the Language in the Translate Interface form (refer to Figure 2), all new forms are opened from now on translated in the newly selected language. Old forms are still in the old language. To translate all forms (including "Translate Interface") in the selected language, just click on the Translate All Opened Forms button.

In a real application, the language setting should be available in a Configuration module, and an OK or Apply button will provide the function of the Translate All Opened Forms button. Users should also be allowed to use a Cancel button to abort configuration changes, including Language.

Conclusion
In this article, I showed you how to make your forms truly "multi-language" aware. However, to develop a fully "multi-language" aware application, there are few more issues to take care of...

Menus
VFP menus aren't object-oriented. However, it's quite easy to provide a translated application's menu. Since there are generally not too many menus in an application, you can create a set of menus for each language you want to provide. Then, when you instantiate the menu, you just have to call the one appropriate for the language setting. You can name the menus for a language set by adding LangID as a prefix to the menu file name (for instance, en_MainMenu, en_NavigateMenu, and so on).

Strings used in MessageBox and other places in the code
You can create a table with three fields�LangID, StringID, and StringValue. You need to create a method of the bzTranslation class that will receive as a parameter a StringID (number) and will return the corresponding StringValue for the current application's LangID. You can also create a wrapper method for the global application's class in order to make the code independent of the bzTranslation object. A call to the MessageBox will look like this:

#define STRID_MSGTST    1
* 
Local nRetVal as integer
nRetVal = MessageBox(;
goApp.TranslateString(STRID_MSGTST), 4   32)

Reports and labels
Reports and labels aren't object-oriented, so for them you can't use an approach similar to the one used for forms and other containers. Since there are usually a lot of reports in an application, you can't use the same approach as for menus either.

You can use the Comment property, which is available for every control on a report, as the Object Name. You need to ensure that each Object Name is unique. To help with this, you can write custom code in the ProjectHook's BeforeBuild event to open every report and label and check for Object Name uniqueness in the Comment property.

The second step is the translation. To be able to perform the translation at runtime, you need to write a wrapper around the Report Form and Label Form statements. The wrappers need to do the following:

1. Create a temporary report/label file and fill it with all the records from the original report file.

2. Based on the Object Name value from the Comment field, translate all objects in a temporary report.

3. Call Report or Label statements and pass the temporary report name together with all other parameters.

One last tip about multi-language interfaces: It's well known that the same word has different lengths in different languages. Because of this, in a multi-language interface it's necessary to reserve enough space on a form for the longest possible string. The English language is more analytical than other languages, and the same message in other languages usually requires 120-180% more space. You should be aware of this in order to design a good-looking user interface.

download_1.PNGClick to download source code