Статья FASM - многоязычный интерфейс программ Win-MUI

Пользователям гораздо удобней работать с программой на своём родном языке, а потому поддержка многоязычного интерфейса "Multilingual User Interface" является сейчас одним из обязательных требований заказчиков к программистам. На рынке предлагается множество готовых решений, но все они имеют следующие недостатки:

1. Обычно платные
2. Приводят в существенному увеличению размера софта
3. Являются сложными для понимания
4. Строки хранятся внутри уже скомпилированных DLL, что затрудняет внесение в них правок.

Поэтому в данной статье мы рассмотрим парочку простых методов создания MUI-интерфейса штатными средствами WinAPI. Для описания строк в окне, первая технология использует специально предназначенные для этих целей внешние текстовые файлы с расширением *.LNG, а вторая подразумевает создание одинаковых ресурсов в РЕ-файле, только на разных языках, которые загружаются вручную по выбору из меню, или автоматически, согласно предпочтительного языка в системе "Preferred UI Languages".

Оглавление:

1. Теоретические основы​
2. Пример многоязычной программы с разными ресурсами​
- устройство и разбор секции ресурсов​
3. Пример многоязычной программы с внешними файлами​
- системная поддержка файлов ini/lng​
4. Выводы и рекомендации​



1. Теоретические основы

Как правило, установщики Windows поставляются на каком-то одном языке, например русская версия, английская, китайская, и т.д. От этого выбора зависит, на каком языке будет в дальнейшем отображаться весь оконный интерфейс системы, т.е. текст в заголовках, на кнопках, в различного рода сообщениях, и многое другое. Однако это вовсе не означает, что установив английскую версию Win мы будем вынуждены теперь пройти курсы английского, чтобы общаться с ОС на предлагаемом ею языке.

Дело в том, что начиная Win2k система официально стала много-язычной, в отличии от Win3.1, которая считалась моно с поддержкой исключительно Eng. Теперь в каталоге system32 можно обнаружить несколько папок с именами разных локалей типа en-EN, ru-RU и т.д - это и есть языковые пакеты, на которые мы можем переключиться в любой момент уже после установки англо-язычной версии ОС. На своей Win7 я насчитал всего 36 таких папок, и в каждой из них имеются свои системные либы comdlg32.dll + comctl32.dll (dialog и controls соответственно) для разных языков.

Сменить интерфейс системы можно в оснастке "Язык и региональные стандарты" панели управления. Обратите внимание, что раскладка клавиатуры и язык интерфейса это разные вещи, а потому не нужно их путать. Если среди уже предустановленных языковых пакетов не окажется нужно вам языка, система предложит скачать его с сервера Microsoft.

WinLng.webp


1.1. Определение системной локали

Для кодирования языков система использует идентификаторы LCID или "Locale Identifier", которые строго определены международным стандартом NLS "National Language Standart". Размер LCID равен 32-битный дворд, при чём старшие 12-бит лежат в резерве так, что в младших 16-ти можно закодировать аж 65.536 различных наборов летописи, хотя реально в мире существует намного меньше языков.

Поэтому непосредственно язык LANGUAGE кодируется в младших 10 битах LCID = 1024 различных вариантов, а старшие 6 бит выделяются для точного определения синтаксиса внутри конкретного языка SUBLANG - это позволяет уточнить ещё 64 типа. Более того имеются доп.4 бита для указания приоритета сортировки SORT-ID на случай, когда нужно выбрать одного из двух одинаковых зайцев. Поле используется редко, а потому можно им принибречь.

LcidFormat.webp

Среди инклудов fasm'a имеется ..\equates\kernel32.inc, где среди прочего можно найти определение констант LCID. Обратите внимание на значения из списка SUBLANG - для их кодирования используется инструкция сдвига влево на 10-бит SHL, что как-раз представлено на рис.выше. То-есть он попадает в зелёную область. Таким образом, для русской локали получаем LCID=0x0419, а для дефолтной американо-английской LCID=0x0409. Если в LCID задействовано поле SORT_ID, его значение будет больше 2-х байт, например 0x010419.

C-подобный:
;// Language identifiers

LANG_CHINESE   = 04h
LANG_ENGLISH   = 09h
LANG_RUSSIAN   = 19h
........

;// Sublanguage identifiers

SUBLANG_CHINESE_TRADITIONAL  =  01h shl 10
SUBLANG_CHINESE_SIMPLIFIED   =  02h shl 10
SUBLANG_CHINESE_HONGKONG     =  03h shl 10
SUBLANG_CHINESE_SINGAPORE    =  04h shl 10

SUBLANG_ENGLISH_US           =  01h shl 10
SUBLANG_ENGLISH_UK           =  02h shl 10
SUBLANG_ENGLISH_AUS          =  03h shl 10
SUBLANG_ENGLISH_CAN          =  04h shl 10
SUBLANG_ENGLISH_NZ           =  05h shl 10
SUBLANG_ENGLISH_EIRE         =  06h shl 10

SUBLANG_RUSSIAN_RUSSIA       =  01h shl 10
.....

Все локали в системе собираются в свои группы, например Западная Европа, далее Кириллица, Китайская, Греческая, Прибалтика, и т.д. Из покон веков среди WinAPI существовала функция EnumSystemLanguageGroups(), которая возвращает полный их список. Родственная ей EnumLanguageGroupLocales() перечисляет уже все локали внутри группы.

Но проблема в том, что функи эти дампят информацию без фильтра реально поддерживаемых в текущей системе, т.е. просто все возможные варианты в природе. Поэтому если мы хотим получить фактический лист, нужно прочитав строку LCID проверить её наличие в папке system32 через FindFirstFile(), хотя можно просто запросить атрибуты папки по имени GetFileAttributes(). Если последняя вернёт ошибку EAX=-1, значит прокол. Вот пример такого парсера и результат его работы:

C-подобный:
format  pe64 console
include 'win64ax.inc'
entry start
;//-------------
section '.data' data readable writeable

fPath   rb  64
buff    db  0
;//-------------
section '.text' code readable executable
start:  sub     rsp,8

        invoke  EnumSystemLanguageGroups,EnumGroup,1,0  ;// 1 = LGRPID_INSTALLED

       cinvoke  _getch
       cinvoke  exit,0

align 8
proc  EnumGroup  id, groupStr
        mov     [id],rcx
        mov     [groupStr],r8

        invoke  CharToOem,r8,buff
       cinvoke  printf,<10,10,' ID-%02d  %s',0>,[id],buff

        invoke  EnumLanguageGroupLocales,EnumLocales,[id],0,0
        mov     eax,1
        ret
endp

align 8
proc  EnumLocales lcid
        mov     [lcid],rdx

        invoke  LCIDToLocaleName,rdx,buff,16,0
        mov     rsi,buff
        mov     rdi,rsi
        mov     ecx,12
@@:     lodsw            ;//<--- UnicodeToAnsi
        stosb
        loop    @b

       cinvoke  wsprintf,fPath,<'C:\Windows\system32\%s',0>,buff
        invoke  GetFileAttributes,fPath
        or      eax,eax
        js      @f
       cinvoke  printf,<10,'        0x%04x = %s',0>,[lcid],buff
@@:     mov     eax,1
        ret
endp

;//-------------
section '.idata' import data readable writeable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll',user32,'user32.dll'
include 'api\kernel32.inc'
include 'api\user32.inc'
include 'api\msvcrt.inc'

Так получим присутствующие в нашей системе языковые пакеты из \system32, которых лично у меня оказалось всего 36 штук. Здесь ID определяет группу (будет одинакова во всех осках):

lcid.webp


1.2. Предпочтительный язык интерфейса

Одним из компонентов загрузчика РЕ-файлов является лоадер ресурсов. Он оперирует такими api как LoadResource(), LoadString(), LoadCursor(), и многими другими. Именно эти функции принимают участие в создании обычных и диалоговых окон, а значит невидимой нитью связаны и с системной локалью LCID.

На этапе отображения окна CreateWindow() или DialogBoxParam(), система начинает перебирать список предпочтительных языков - его возвращает функция из либы Kernel32.dll GetSystemPreferredUILanguages(). Получив самый первый язык из этого списка, система пытается создать окно программы на нём и если прокол, то берёт следующий в очереди, и т.д. Важно понять, что приоритет отдаётся именно первому языку из предпочтительных Preferred.

Но что примечательно, ОС предлагает нам не только запросить этот список, но и функцией SetThreadPreferredUILanguages() выставить в нём приоритетный язык для любого потока программы (а их у процесса может быть несколько). Если это действительно так, то не затрачивая особых усилий мы сможем написать мультиязычное приложение. Главное требование - сменить предпочтительный язык до запуска окна или формы, поскольку на уже отображённую в память форточку api никак не действует, хотя и возвращает ОК.


2. Пример многоязычной программы с разными ресурсами

Теперь проверим этот тезис на практике.
Значит алгоритм программы должен выглядеть примерно так:

1. Создаём обычное диалоговое окно на русском языке, с описанием его элементов в секции-ресурсов.​
2. Создаём в ресурсах и "Меню" окна тоже на русском, чтобы была возможность менять язык интерфейса.​
3. Создаём в ресурсах дубликаты и диалогового окна, и меню.​
4. Меняем все текстовые строки в дубликатах на английский язык.​
5. В обработчике меню уничтожаем DestroyWindow() текущее окно.​
6. В обработчике меню меняем предпочтительный язык с русского на английский.​
7. В обработчике меню создаём новое окно!​
8. Главное - не перепутать местами пункты 6 и 7, иначе фокус не пройдёт.​

Теперь поговорим о нюансах.
Суть в том, что мы определяем по 2 ресурса на разных языках LCID, но обязательно с одинаковым ID самих ресурсов. Вот пример из моей демки:

C-подобный:
section '.rsrc' data resource readable
directory  RT_MENU,   menus,\
           RT_DIALOG, dialogs

resource menus,   037, LANG_RUSSIAN + SUBLANG_RUSSIAN_RUSSIA, rusMenu,\
                  037, LANG_ENGLISH + SUBLANG_ENGLISH_US,     engMenu

resource dialogs, 100, LANG_RUSSIAN + SUBLANG_RUSSIAN_RUSSIA, rusForm,\
                  100, LANG_ENGLISH + SUBLANG_ENGLISH_US,     engForm

Здесь видно, что ID обоих ресурсов "Menu" равен 37, а вот локали LCID будут у них уже разные - для LANG_RUSSIAN получим 0x419, а для LANG_ENGLISH 0x409. Аналогичную картину наблюдаем и в случае ресурса "Dialogs".

Чтобы убедиться в правильном оформлении ресурсов, можно вскормить прожку вьюверу "CFF-Explorer". И точно.. видим 2 дира с ID=4 aka menus, и 5 aka dialogs, ID самих ресурсов 37 и 100, и в каждом из них по 2 языка с LCID=1033=0x409 для Eng, плюс LCID=1049=0x419 для версии Ru. Это значит, что мы всё сделали правильно!

Cff_rs.webp

Как результат, в момент запуска нашего приложения загрузчик ресурсов запросит у системы предпочтительный язык LCID, найдёт его в нашей секции-ресурсов, и магическим образом сам загрузит нужное окно. Всё что требуется от нас - это при смене языка в меню, тут-же подменить и предпочтительный LCID. Кстати вот идентификаторы каталогов в ресурсах, которые определены на глобальном уровне всех операционных систем Windows (см.разделы AKA выше).

Rt_Const.webp

А так должно выглядеть описание ресурсов на разных языках:

C-подобный:
dialog rusForm,'** MUI диалог v0.1 **',0,0,180,162, WS_CAPTION + WS_SYSMENU + DS_CENTER,,,'Verdana',8

  dialogitem 'Static','Процессор', -1,010,005,170,010, WS_VISIBLE + SS_CENTER
  dialogitem 'Static','',ID_CPU,      010,020,160,010, WS_VISIBLE + SS_CENTER+SS_SUNKEN
  dialogitem 'Static','Ядер:',     -1,010,035,030,010, WS_VISIBLE + SS_LEFT
  dialogitem 'Static','',ID_Cores,    035,035,023,009, WS_VISIBLE + SS_CENTER+SS_SUNKEN
  dialogitem 'Static','Частота:',  -1,065,035,040,010, WS_VISIBLE + SS_RIGHT
  dialogitem 'Static','',ID_Freq,     110,035,060,009, WS_VISIBLE + SS_CENTER+SS_SUNKEN

  dialogitem 'Button','Выход',IDCANCEL,010,125,160,013,WS_VISIBLE + BS_DEFPUSHBUTTON
enddialog
;//---------------------------
dialog engForm,'** MUI Dialog v0.1 **',0,0,180,162, WS_CAPTION + WS_SYSMENU + DS_CENTER,,,'Verdana',8

  dialogitem 'Static','Processor', -1,010,005,170,010, WS_VISIBLE + SS_CENTER
  dialogitem 'Static','',ID_CPU,      010,020,160,010, WS_VISIBLE + SS_CENTER+SS_SUNKEN
  dialogitem 'Static','Cores:',    -1,010,035,030,010, WS_VISIBLE + SS_LEFT
  dialogitem 'Static','',ID_Cores,    040,035,020,009, WS_VISIBLE + SS_CENTER+SS_SUNKEN
  dialogitem 'Static','Frequency:',-1,065,035,040,010, WS_VISIBLE + SS_RIGHT
  dialogitem 'Static','',ID_Freq,     110,035,060,009, WS_VISIBLE + SS_CENTER+SS_SUNKEN

  dialogitem 'Button','Exit',IDCANCEL,010,125,160,013, WS_VISIBLE + BS_DEFPUSHBUTTON
enddialog

Result1.webp


3. Пример многоязычной программы с внешними файлами

Предложенный выше вариант многоязычной программы имеет 1 основной недостаток - для добавления новых языков требуется полная перекомпиляция исходного кода. Чтобы решить эту проблему, можно создать единственное дефолтное окно, а при запросе на смену языка, читать текстовые строки из заранее подготовленных внешних файлов. Тогда, чтобы добавить в программу новый язык, достаточно будет пользователю самому создать обычный файл с расширением LNG, в котором будет храниться пара [Ключ=Значение] примерно в таком виде:

C-подобный:
;//-- Файл Russian.lng -----
[Language]
Name=Russian

[MainWindow]
3001=Процессор
3002=Ядер:
3003=Частота:
3004=Физическая DDR-SDRAM:
3005=Размер файла подкачки:
3006=Виртуальная память:
3007=Свободно физической:
3008=Выход

;//-- Файл France.lng ------
[Language]
Name=France

[MainWindow]
3001=Processeur
3002=Coeurs:
3003=La'cadence:
3004=DDR memoire physique:
3005=Taille du fichier d'echange:
3006=Memoire virtuelle:
3007=Memoire physique libre:
3008=Sortie

Если приглядеться, то формат файлов LNG является точной ксерокопией файлов инициализации INI, поэтому всё сказанное ниже можно применить и в контексте создания файлов конфига CFG, т.к. вся эта тройка одного поля ягоды. Кстати данная техника находит широкое применение во всей программной области, например в механизмах подключения плагинов к уже готовому софту лишь с тем отличием, что вместо txt здесь используют dll.

Раз уж сами майки предлагают нам форматы файлов cfg\ini\lng, то естественно и в ОС имеется их системная поддержка в лице api Get\SetPrivateProfileString(). На MSDN сказано, что мол функа эта только для Win16 и нужно хранить инфу не в файлах, а в реестре. Но для нашего случая реестр не преемлен в принципе, тем-более что указанная api до сих пор благополучно экспортируется из Kernel32.dll даже на Win10-11.

C-подобный:
DWORD GetPrivateProfileString(     //<--- возвращает длину скопированной в буфер строки!
  [in]  LPCTSTR lpAppName,         // имя раздела в файле - в данном случае "Language" или "MainWindow"
  [in]  LPCTSTR lpKeyName,         // имя ключа - здесь "Name" или "3001" и т.д.
  [in]  LPCTSTR lpDefault,         // что выводить, если ключ не найден (строка по умолчанию, обычно "пробел")
  [out] LPTSTR  lpReturnedString,  // линк на приёмный буфер, куда вернётся значение ключа
  [in]  DWORD   nSize,             // размер буфа (см.строку с макс длиной в файле)
  [in]  LPCTSTR lpFileName         // полный путь с именем LNG-файла
);

Единственная проблема - функцию нужно вызывать для каждого ключа в файле, т.к. за один выстрел она возвращает только одно значение строки по указанному ключу, которое нужно будет сразу отправить в элемент окна через SendMessage(). Поэтому на практике используют не осмысленные имена ключей типа StaticCpu=Процессор, а строковое представление последовательных чисел как в данном примере 3001=Процессор и т.д. Именно такой подход позволяет читать сразу все строки в цикле, иначе создание многоязычного интерфейса по такому методу превратится в настоящий ад, ведь в реальных программах могут быть сотни строк.

Вот алгоритм, что нам придётся реализовать:

1. Подготовить несколько файлов LNG на разных языках.​
2. Создать обычным способом окно приложения (у меня диалоговое в ресурсах).​
3. Функцией FindNextFile() найти все LNG в текущей директории (требуется цикл).​
4. На каждой итерации цикла выше, прочитать ключи "Name" из найденного LNG, и сохранить их в своей базе.​
5. Отобразить основное окно приложения.​
6. Читая созданную базу, через AppendMenu() динамически создать пункты подменю для языков.​
7. При выборе языка в меню, из соответствующего файла LNG прочитать все строки, перезаписывая ими элементы окна.​

Не смотря на то, что данный метод реализации MUI-интерфейса немного сложнее предыдущего, он позволяет малой кровью создать приложение на абсолютно любом языке мира - просто поместите рядом с EXE свой файл LNG, и опишите в нём соответствующие строки. При этом нужно будет строго придерживаться дефолтного формата.

В частности, значение ключа "Name" должно обязательно совпадать с именем LNG-файла, т.к. динамическое создание пунктов меню и открытие файлов для чтения используют одно и то-же имя. Ради прикола попробуйте создать файл со-строками от фонаря типа 3001=Груша, 3002=Автомобиль, и т.д. - код не проверят смысла строк, а потому будет тупо отображать то, что ему попадётся под руку.

Result2.webp


4. Выводы и рекомендации

Создать в своей программе многоязычный интерфейс достаточно просто, и теперь дело за малым - придумать такой софт, чтобы он был интересен пользователям во всём мире. Тогда его начнут переводить на различные языки, что существенно повысит планку дружелюбности по отношению к юзерам. В скрепке найдёте три исходника на ассемблере fasm представленных здесь демок, а так-же уже скомпилированные их бинари для тестов. Всем удачи, пока!
 

Вложения

  • MUI.ZIP
    MUI.ZIP
    14,3 КБ · Просмотры: 6
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab