In my book “Visual Programming” I discussed in detail the issues of localization and internationalization, it is time to apply this knowledge in a new project.

By default, My Visual Database detects the locale of the operating system and adapts to it. But, firstly, MVDB only supports two languages (Russian and English), secondly, the OS localization language is not always the desired language for the user, and thirdly, the application has components created by the user, as well as all kinds of test data added by scripts . Therefore, for applications that should be used all over the world, it is necessary to provide a convenient localization mechanism that does not require rewriting the code of the application itself.

For localization, text files will be used that store key=value pairs. But for more flexibility, two types of keys will be used:

  • Static (form property value or system text resource)
  • Dynamic (custom text resource)

Static key

These keys are processed once, when the program is started. After the value is assigned, the pair is removed from storage.

A static key has three types:

  • R.<Form name>.<Property>
  • R.<Form name>.<Component name.Property>
  • T.<Text resource identifier>

The R or T prefix is used to designate a dynamic key. The Resource_L10App() procedure is used to process keys. The procedure is cumbersome, since in the process of processing it is necessary to bring

procedure Resource_L10App;
// localization of all application forms
var
  i: integer;
  tmpWords: array of string;
  tmpForm: TForm;
  tmpComponent: TComponent;
  tmpValue: string;
  // helper procedure that makes the code more compact
  procedure RE;
  begin
    RaiseException('Resource_L10Form() Property not found '+tmpWords[RESOURCE_I_FORM]+'.'+tmpWords[RESOURCE_I_COMPONENT]+'.'+tmpWords[RESOURCE_I_PROPERTY]);
  end;
begin
  for i:=StringRes.Lines.Count - 1 downto 0 do
  begin
    tmpWords := SplitString( StringRes.Lines.Names(i) , '.');
    // translation of words and phrases built into MVDB
    if tmpWords[RESOURCE_I_PREFIX] = RESOURCE_TRANSLATE then
    begin
      tmpValue := StringRes.Lines.Strings(i);
      Delete(tmpValue,1,Pos('=',tmpValue));
      Translate( tmpWords[RESOURCE_I_IDENTIFIER],tmpValue );
      StringRes.Lines.Delete(i); 
    end
    else
    // translation of static properties
    if tmpWords[RESOURCE_I_PREFIX] = RESOURCE_PREFIX then
    begin
      if length(tmpWords)<3 then
        RaiseException('Resource_L10Form() invalid data format: '+StringRes.Lines.Strings(i) );
      //
      tmpValue := StringRes.Lines.Strings(i);
      Delete(tmpValue,1,Pos('=',tmpValue));
      //
      tmpForm := GetFormByName(tmpWords[RESOURCE_I_FORM]);
      if tmpForm = nil then
        RaiseException('Resource_L10Form() Form not found '+tmpWords[RESOURCE_I_FORM]);
      //
      if length(tmpWords)=3 then
      begin 
        case UpperCase( tmpWords[RESOURCE_I_COMPONENT] ) of
        'CAPTION': tmpForm.Caption := tmpValue;
        else RaiseException('Resource_L10Form() Form property not found '+tmpWords[RESOURCE_I_FORM]+'.'+tmpWords[RESOURCE_I_COMPONENT]);
        end;
      end
      else 
      begin
      FindC(tmpForm,tmpWords[RESOURCE_I_COMPONENT],tmpComponent,False);
      if tmpComponent = nil then
        RaiseException('Resource_L10Form() Component not found '+tmpWords[RESOURCE_I_FORM]+'.'+tmpWords[RESOURCE_I_COMPONENT]);
      // must be cast to a specific class
      if (tmpComponent is TLabel) or (tmpComponent is TdbLabel) then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TLabel(tmpComponent).Caption := tmpValue;
        'HINT': TLabel(tmpComponent).Hint := tmpValue;
        else RE;
        end
      else
      if (tmpComponent is TdbCheckBox) or (tmpComponent is TCheckBox) then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TCheckBox(tmpComponent).Caption := tmpValue;
        'HINT': TCheckBox(tmpComponent).Hint := tmpValue;
        else RE;
        end
      else
      if  (tmpComponent is TTabSheet) or (tmpComponent is TdbTabSheet) then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TTabSheet(tmpComponent).Caption := tmpValue;
        'HINT': TTabSheet(tmpComponent).Hint := tmpValue;
        else RE;
        end
      else
      if tmpComponent is TGroupBox then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TGroupBox(tmpComponent).Caption := tmpValue;
        else RE;
        end
      else
      if tmpComponent is TRadioButton then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TRadioButton(tmpComponent).Caption := tmpValue;
        else RE;
        end
      else
      if (tmpComponent is TButton) or (tmpComponent is TdbButton) then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TButton(tmpComponent).Caption := tmpValue;
        'HINT': TButton(tmpComponent).Hint := tmpValue;
        else RE;
        end
      else
      if tmpComponent is TMenuItem then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'CAPTION': TMenuItem(tmpComponent).Caption := tmpValue;
        else RE;
        end
      else
      if tmpComponent is TdbEdit then
        case UpperCase( tmpWords[RESOURCE_I_PROPERTY] ) of
        'HINT': TdbEdit(tmpComponent).Hint := tmpValue;
        'TEXTHINT': TdbEdit(tmpComponent).TextHint := tmpValue;
        else RE;
        end
      else
        RaiseException('Resource_L10Form() Unsupported class '+tmpWords[RESOURCE_I_FORM]+'.'+tmpWords[RESOURCE_I_COMPONENT]+' - '+tmpComponent.ClassName);
      end;
      StringRes.Lines.Delete(i);
    end;
  end;
end;Code language: Delphi (delphi)

Dynamic key

Dynamic keys are in memory for the duration of the application, as they may be needed at any time.

The R() function is used to work with dynamic keys. The conciseness of its name is due to the fact that it will have to be used in all scripts that programmatically set properties to interface components.

function R(AName:string; ADefValue:string='???'):string;
// извлечение ресурсов
begin
  if (StringRes <> nil) and (StringRes.Lines.IndexOfName(AName) < 0) then
    Result := ADefValue
  else
    Result := StringRes.Lines.Values(AName);
end;Code language: Delphi (delphi)

Usually the text value is not stored in the code, but placed in a constant:

const
  RESOURCE_CHANGE_LANGUAGE = 'Switch language';
begin
  Restart(True, RESOURCE_CHANGE_LANGUAGE);Code language: Delphi (delphi)

To translate such a piece of code to use a language resource, a small and safe change is required:

ShowMessage(R('RESOURCE_CHANGE_LANGUAGE',RESOURCE_CHANGE_LANGUAGE));Code language: Delphi (delphi)

The safety of the change is that if the specified resource is not found or the resource system is not involved, then the R() function will return the default value, which is in a constant. This trick is also used to create a “default” localization file – it can be empty, since all the necessary values will be taken from constants. But if you wish, you can fix the inscription without changing the code.

If you decide to store all text resources in a separate file and not use string constants to store default values inside the code, then it is acceptable to call the R() function without a second parameter.

ShowMessage(R('RESOURCE_CHANGE_LANGUAGE'));Code language: Delphi (delphi)

This reduces the size of the source code, and in case you forgot to describe the corresponding text resource, three question marks will appear on the screen.

Resource initialization

During initialization, which occurs in the Resourse_Init() procedure, data is loaded into the TdbMemo class component, which can correctly work with files in UTF-8 format with BOM.

Important! Only the specified format must be used: UTF-8 with BOM
procedure Resource_Init();
// initialization
var
  tmpFileList: array of string;
  i: integer;
  s: string;
  tmpDir: string;
begin
  tmpDir := ExtractFilePath(Application.ExeName)+RESOURCE_DIR;
  if DirectoryExists(tmpDir) then
  begin
    // languages - by the number of files. Each file for its own language
    Lаnguages := TStringList.Create;
    StringRes := TdbMemo.Create(MainForm);
    StringRes.Parent := MainForm;
    StringRes.Visible := False;
    StringRes.WordWrap := False;
    tmpFileList := SplitString( GetFilesList(tmpDir,'*'+RESOURCE_EXT, False), chr(13) );
    for i:=0 to Length(tmpFileList) -1 do
    begin
      s := Trim(ExtractFileName(tmpFileList[i]));
      if s<>'' then
      begin
        delete(s,length(s)-3,4);
        Lаnguages.Add(s);
      end;
    end;
    // current language - read from settings
    currentLаnguage := IniFile_Read( RESOURCE_INI_SECTION, RESOURCE_INI_CUR_LANG, '' );
    i := Lаnguages.IndexOf(currentLаnguage);
    if i = -1 then
      i := 0;
    if i = -1 then
      RaiseException('Resurce_Init() - language not found: '+currentLаnguage)
    else
    begin
      StringRes.Lines.LoadFromFile(tmpDir + '\'+ Lаnguages.Strings(i) + RESOURCE_EXT);
    end;
    // static localization
    Resource_L10App;
  end;
end;Code language: PHP (php)

An example resource file:

APP_ABOUT_CAPTION=About
APP_CONFIRM_RESTART=A program restart is required to apply the changes. Execute now?
APP_COPYRIGHT=2023 Konstantin Pankov
APP_NAME=Developer's guide
DTF_CLASSEVENT_CAPTIONS=Name,Description
DTF_CLASSTYPE_TREE_CAPTIONS=Name
DTF_OPERATOR_TREE_CAPTIONS=Name,Description
DTF_PROJECT_TREE_CAPTIONS=Name,Path,Status,Description
DTF_PROPERTY_CAPTIONS=Name,Type,Description
DTF_PROPERTY_VARIABLELIST_CAPTIONS=Name,Type,Description
DTF_TASK_TREE_CAPTIONS=Name
DTF_TYPECONST_CAPTIONS=Name,Description
R.efmClassEvent.Caption=Event
R.efmClassEvent.btnCancel.Caption=Cancel
R.efmClassEvent.btnNew_Example.Hint=Add...
R.efmClassEvent.btnSave.Caption=Save
R.efmClassEvent.labDescription.Caption=Description
R.efmClassEvent.labExample.Caption=Example
R.efmClassType.btnSave.Caption=Save
R.efmClassType.labDescription.Caption=Description
Code language: SQL (Structured Query Language) (sql)

Language switching

To change the language, we need a menu item. To create it, the Resource_CreateMenu() procedure is used, and the procedure for setting the selected language Resource_SetLanguage() is called in the click handler

procedure Resource_CreateMenu( AForm:TForm );
// creating menu item "Language"
var
  i: integer;
  tmpItem: TMenuItem;
  tmpTopItem: TMenuItem;
begin
  // create a top level menu item
  tmpTopItem := TMenuItem.Create( AForm );
  tmpTopItem.Name := 'mniLanguage';
  tmpTopItem.Caption := R('RESOURCE_MENU_LANGUAGE',RESOURCE_MENU_LANGUAGE);
  AForm.Menu.Items.Insert(1,tmpTopItem);
  // add menu items to change language
  for i := 0 to Lаnguages.Count - 1 do
  begin
    tmpItem := TMenuItem.Create( AForm );
    tmpItem.Name := 'mniSetLanguage_'+IntToStr(i);
    tmpItem.Caption := Lаnguages.Strings(i);
    tmpItem.OnClick := @Resource_MenuItem_OnClick;
    tmpItem.RadioItem := True; 
    tmpItem.GroupIndex := 1; 
    tmpItem.Autocheck := True; 
    // highlight current language
    if Lаnguages.Strings(i) = currentLаnguage then
      tmpItem.Checked := True;
    tmpTopItem.Insert(i,tmpItem);
  end;
end;

procedure Resource_MenuItem_OnClick (Sender: TObject; );
// Click on the language selection menu item
begin
  // remove special characters that were added automatically
  Resource_SetLanguage( ReplaceStr(TMenuItem(Sender).Caption,'&','') );
end;Code language: PHP (php)

Due to the presence of dynamically changing text resources, changing the language requires an application restart. Restart may be needed in other cases, so it is wise to provide a separate Restart() procedure for it, which displays a confirmation form before performing a restart.

procedure Resource_SetLanguage(AName:string);
// set language
// changes will take effect after reboot
begin
  IniFile_Write( RESOURCE_INI_SECTION, RESOURCE_INI_CUR_LANG, AName );
  Restart(True, R('RESOURCE_CHANGE_LANGUAGE',RESOURCE_CHANGE_LANGUAGE))
end;

procedure Restart(AConfirm:boolean = False; AReason:string);
begin
  if (not AConfirm) or (MessageBox( R('APP_CONFIRM_RESTART',APP_CONFIRM_RESTART) ,AReason,MB_OKCANCEL ) = mrOK) then
  begin
    frmMain.Close; // close the current application. In fact, this command only creates a message, in fact, the closing will occur after the completion of the current procedure.
    OpenFile( Application.ExeName ); // application launch
  end;
end;Code language: Delphi (delphi)

Known Issues

This localization does not end there. There is a small list of components and properties with a non-trivial way to access them, which also needs to be processed. This mainly applies to table headers and drop-down lists on service forms. You also need to localize context menus for tables, trees, and other components. The most difficult operation is localizing the translation of the authentication error message on the password and login form, which will require replacing the standard button. So the topic remains open.

Bonus

The application makes extensive use of dynamic forms from the DTF.pas module. These forms were created in the UserApp_InitForm() procedure, but due to the uniformity of string parameters, I decided to add the DTF_CreateForms() procedure, which does all the work of creating forms based on the dforms.ini text file, and at the same time uses the newly created localization system.

procedure DTF_CreateForms;
// creation of dynamic forms according to the list located in the dforms.ini file
var
  tmpIniFile: TIniFile;
  tmpFormList:TStringList;
  i:integer;
  tmpForm: TForm;
  tmpSName: string;

  function S(AName:string):string;
  begin
    Result := tmpIniFile.ReadString( tmpFormList.Strings(i), AName, '' );
  end;

begin
  tmpIniFile := TIniFile.Create( ExtractFilePath(Application.ExeName)+'dforms.ini' );
  tmpFormList := TStringList.Create;
  tmpIniFile.ReadSections(tmpFormList);
  for i:=0 to tmpFormList.count - 1 do
  begin
    tmpSName := 'DTF_'+UpperCase(tmpFormList.Strings(i))+'_CAPTIONS';
    tmpForm := DTF_Create(tmpFormList.Strings(i),S('table'),S('fields'), R( tmpSName,S('captions')),S('sort'),S('parentField'),S('filter'),S('isDetail')='True');
    Form_ShowOnWinControl( tmpForm, TWinControl( FindCF( S('parentControl') ) ) );
  end;
  tmpFormList.Free;
  tmpIniFile.Free;
end;Code language: Delphi (delphi)

The structure of the dforms.ini file is simple enough to add new forms, if necessary, directly in a text editor.

[Keyword]
table=keyword
fields=keyword.name,keyword.Description
captions= Name, Description
sort=keyword.name
parentControl=frmMain.tshKeyword
[Operator_Tree]
table=operator
fields=operator.name,operator.Description
captions= Name, Description
sort=ParentID,name
parentField=parentID
parentControl=frmMain.tshOperator
[ClassType_TypeList]
table=classType
fields=classType.name,classType.Description
captions= Name, Description
sort=classType.name
filter=isType = 1
parentControl=frmMain.panTypeCode language: SQL (Structured Query Language) (sql)

And FindCF() has been added to the collection of useful functions, which can find components on the form by their full path <Form name>.<Component name>

function FindCF(AName:string): TComponent;
// finds the component by full name
var
  tmpForm:TForm;
  tmpWords: array of string;
begin
  tmpWords := SplitString(AName,'.');
  if Length(tmpWords) < 2 then
    RaiseException('FindCF('+AName+') invalid argument');
  tmpForm := GetFormByName( tmpWords[0] );
  if tmpForm = nil then
    RaiseException('FindCF('+AName+') Form not found '+tmpWords[0]);
  Result := tmpForm.FindComponent(tmpWords[1]);
  if Result = nil then
    RaiseException('FindCF('+AName+') Component not found '+tmpWords[1]);
end;Code language: Delphi (delphi)

Links

Leave a Reply

Your email address will not be published. Required fields are marked *