Przystępując do tłumaczenia aplikacji WPF miałem dokładny plan jak się za to zabrać. Mianowicie postanowiłem użyć narzędzia LocBaml, o którym dowiedziałem się z training kit'a do egzaminu 70-502. Opis całej procedury można znaleźć tutaj. Praktyka pokazała jednak, że narzędzie to pozostawia bardzo dużo do życzenia. Dalej opiszę kolejne kroki pracy z LocBaml wraz z komentarzem jak to wygląda w praktyce.
msbuild /t:updateuid NAZWA_PROJEKTU.csproj
Wszystkie pliki XAML, które mają zostać przetworzone przez msbuild powinny być checkoutowane (brzmi okropnie ale nie przychodzi mi do głowy dobry polski odpowiednik). Narzędzie to działa i dobrze i źle. Dobrze bo rzeczywiście wygeneruje dla wszystkich kontrolek UID. Źle bo zrobi to dla wszystkich kontrolek, a właściwie powinienem napisać dla wszystkich elementów dokumentu XAML. msbuild nie wykonuje jakiejkolwiek analizy przetwarzanych elementów. Skutkuje to dużym bałaganem. Po przeprowadzeniu tej operacji w plikach XAML pojawi się bardzo dużo, niepotrzebnych, śmieciowych UID'ów, na przykład po co nadawać identyfikator UID kontrolce Border, Line lub StackPanel. Potem aż bolą oczy jak się patrzy na tak przetworzony XAML. Moja rada jest taka. Jeśli chcemy stosować LocBaml to zawczasu, tworząc interfejs użytkownika, powinniśmy nadawać kontrolkom UID'y.
LocBaml.exe /parse NAZWA_PROJEKTU.resources.dll /out:NAZWA_PLIKU_WYJSCIOWEGO.CSV
Skompilowane zasoby czyli pliki dll umieszczane są w podkatalogach o nazwach zgodnych z kulturą (językiem) zasobów np.: en-US, pl-PL itp. Jeśli nasz projekt nazywa isę SimpleApplication to plik dll z zasobami będzie nazywał się SimpleApplication.resources.dll. W praktyce wydanie tego polecenia skończy się błędem:
Could not load file or assembly '...' or one of its dependencies. The system cannot find the file specified.
Rozwiązanie jest proste ale trochę upierdliwe. Otóż plik LocBaml.exe, plik z zasobami NAZWA_PROJEKTU.resources.dll oraz plik exe/dll powstały po skompilowaniu aplikacji/biblioteki muszą znajdować się w tym samym folderze. Najszybciej to oskryptować. Podobny ale trochę inny komunikat o błędzie:
Could not load file or assembly '...' or one of its dependencies. An attempt was made to load a program with an incorrect format.
Otrzymamy jeśli nasza aplikacja ma ustawioną docelową platformę na x86. W takim wypadku możemy zmienić ustawienia naszej aplikacji, przekompilować LocBaml na x86 albo użyć narzędzia corflags o czym już zresztą pisałem tutaj lub tutaj.
Tak wygenerowany plik CSV powinniśmy teraz przetłumaczyć. Jeśli użyliśmy msbuild do wygenerowania UID'ów to plik ten będzie zawierał dla dużej aplikacji nawet kilkanaście tysięcy wierszy. W moim przypadku było to około 13 tysięcy wierszy z czego jakieś 10% wymagało przetłumaczenia!!! Po prawdzie tak duża liczba wierszy jest również związana ze sposobem działania LocBaml. Otóż dla każdej kontrolki z UID'em w pliku CSV znajdziemy wiele wierszy. Na przykład dla kontrolki TextBlock możemy zlokalizować, co oczywiste, właściwość Text ale również mniej sensowne jak Foreground czy Margin.
Problem pierwszy związany jest z bugiem w implementacji LocBaml, który objawia się tym, że identyfikatory zasobów w pliku CSV mogą się powtarzać. W związku z tym proces odwrotny czyli wygenerowanie dll'ki na podstawie pliku CSV się nie powiedzie. Rozwiązanie problemu można znaleźć tutaj i jest ono trywialne ale wymaga rekompilacji projektu. Można też, ale tego nie próbowałem, zapewnić, że wszystkie pliki XAML w naszym projekcie mają inne nazwy nawet jeśli znajdują się w innych katalogach.
Pierwszy problem w porównaniu z drugim to nic. Wyobraźmy sobie taką sytuację. Mamy projekt aplikacji WPF i grzecznie, zgodnie z zasadami wszystkie komunikaty wyświetlane użytkownikowi trzymamy w plikach zasobów resx. Po jakim czasie chcemy przygotować angielską wersję aplikacji. Zaczynamy od przetłumaczenia tych zasobów, a więc plik np.: msg.resx kopiujemy i zmieniamy mu nazwę na msg.en-US.resx, a następnie tłumaczymy.
Teraz zabieramy się za XAML. Nadajemy kontrolką UID'y, wyciągamy zasoby do pliku CSV, tłumaczymy i ponownie używamy narzędzie LocBaml do wygenerowania dll'ki z zasobami. Wszystko poszło jak po maśle i zadowoleni chcemy skopiować świeżutką dll'ke z przetłumaczonymi zasobami do katalogu en-US, a tu figa z makiem. W katalogu znajduje się już dll'ka o takiej nazwie. Skąd się wzięła? Została wygenerowana przez VS i zawiera komunikaty, które umieściliśmy w pliku msg.en-US.resx. Mamy więc dwie dll'ki o takiej samej nazwie, jedną z przetłumaczonymi komunikatami, a drugą z przetłumaczonym GUI i musimy umieścić je w tym samym katalogu.
I tutaj zaczynają się schody, wysokie, wąskie, ciemne i niewygodne. Nie pozostaje nic innego jak użyć linkera al.exe. Da się to zrobić ale tak jak powiedziałem jest to bardzo niewygodne. Nie będę tutaj tego opisywał bo moim zdaniem jest to strasznie nudne. Jeśli ktoś tego potrzebuje to zapraszam do kontaktu.
Jeśli chcemy aby nasza aplikacja miała wiele wersji językowych to przygotowujmy się do tego od pierwszej linijki tej aplikacji.
UIDs
Mechanizm wielojęzyczności aplikacji w WPF koncepcyjnie zbliżony jest to tego co znamy z ASP.NET. W szczególności każda kontrolka, która posiada jakieś zasoby do przetłumaczenia powinna mieć odpowiedni identyfikator tzw. UID. Identyfikatory te można nadać automatycznie przy pomocy polecenia:msbuild /t:updateuid NAZWA_PROJEKTU.csproj
Wszystkie pliki XAML, które mają zostać przetworzone przez msbuild powinny być checkoutowane (brzmi okropnie ale nie przychodzi mi do głowy dobry polski odpowiednik). Narzędzie to działa i dobrze i źle. Dobrze bo rzeczywiście wygeneruje dla wszystkich kontrolek UID. Źle bo zrobi to dla wszystkich kontrolek, a właściwie powinienem napisać dla wszystkich elementów dokumentu XAML. msbuild nie wykonuje jakiejkolwiek analizy przetwarzanych elementów. Skutkuje to dużym bałaganem. Po przeprowadzeniu tej operacji w plikach XAML pojawi się bardzo dużo, niepotrzebnych, śmieciowych UID'ów, na przykład po co nadawać identyfikator UID kontrolce Border, Line lub StackPanel. Potem aż bolą oczy jak się patrzy na tak przetworzony XAML. Moja rada jest taka. Jeśli chcemy stosować LocBaml to zawczasu, tworząc interfejs użytkownika, powinniśmy nadawać kontrolkom UID'y.
Wyciąganie zasobów do przetłumaczenia
Po wygenerowaniu UID'ów przechodzimy do następnego kroku czyli używamy LocBaml aby wyciągnać zasoby do przetłumaczenia do pliku CSV. Służy do tego takie polecenie:LocBaml.exe /parse NAZWA_PROJEKTU.resources.dll /out:NAZWA_PLIKU_WYJSCIOWEGO.CSV
Skompilowane zasoby czyli pliki dll umieszczane są w podkatalogach o nazwach zgodnych z kulturą (językiem) zasobów np.: en-US, pl-PL itp. Jeśli nasz projekt nazywa isę SimpleApplication to plik dll z zasobami będzie nazywał się SimpleApplication.resources.dll. W praktyce wydanie tego polecenia skończy się błędem:
Could not load file or assembly '...' or one of its dependencies. The system cannot find the file specified.
Rozwiązanie jest proste ale trochę upierdliwe. Otóż plik LocBaml.exe, plik z zasobami NAZWA_PROJEKTU.resources.dll oraz plik exe/dll powstały po skompilowaniu aplikacji/biblioteki muszą znajdować się w tym samym folderze. Najszybciej to oskryptować. Podobny ale trochę inny komunikat o błędzie:
Could not load file or assembly '...' or one of its dependencies. An attempt was made to load a program with an incorrect format.
Otrzymamy jeśli nasza aplikacja ma ustawioną docelową platformę na x86. W takim wypadku możemy zmienić ustawienia naszej aplikacji, przekompilować LocBaml na x86 albo użyć narzędzia corflags o czym już zresztą pisałem tutaj lub tutaj.
Tak wygenerowany plik CSV powinniśmy teraz przetłumaczyć. Jeśli użyliśmy msbuild do wygenerowania UID'ów to plik ten będzie zawierał dla dużej aplikacji nawet kilkanaście tysięcy wierszy. W moim przypadku było to około 13 tysięcy wierszy z czego jakieś 10% wymagało przetłumaczenia!!! Po prawdzie tak duża liczba wierszy jest również związana ze sposobem działania LocBaml. Otóż dla każdej kontrolki z UID'em w pliku CSV znajdziemy wiele wierszy. Na przykład dla kontrolki TextBlock możemy zlokalizować, co oczywiste, właściwość Text ale również mniej sensowne jak Foreground czy Margin.
Stworzenie pliku dll z przetłumaczonymi zasobami
To jest chyba najtrudniejszy, a zarazem najmniej wygodny krok w całym procesie. W teorii jest to proste. Bierzemy przetłumaczony plik CSV i używamy LocBaml do wygenerowania dll'ki z zasobami o nazwie NAZWA_PROJEKTU.resources.dll, a następnie umieszczamy ją w odpowiednim katalogu aplikacji np.: en-US. Za wczytanie odpowiedniej wersji zasobów odpowiada już silnik WPF, a decyzje podejmuje podobnie jak w ASP.NET czy WinForms na podstawie właściwości Thread.CurrentThread.CurrentUICulture.Problem pierwszy związany jest z bugiem w implementacji LocBaml, który objawia się tym, że identyfikatory zasobów w pliku CSV mogą się powtarzać. W związku z tym proces odwrotny czyli wygenerowanie dll'ki na podstawie pliku CSV się nie powiedzie. Rozwiązanie problemu można znaleźć tutaj i jest ono trywialne ale wymaga rekompilacji projektu. Można też, ale tego nie próbowałem, zapewnić, że wszystkie pliki XAML w naszym projekcie mają inne nazwy nawet jeśli znajdują się w innych katalogach.
Pierwszy problem w porównaniu z drugim to nic. Wyobraźmy sobie taką sytuację. Mamy projekt aplikacji WPF i grzecznie, zgodnie z zasadami wszystkie komunikaty wyświetlane użytkownikowi trzymamy w plikach zasobów resx. Po jakim czasie chcemy przygotować angielską wersję aplikacji. Zaczynamy od przetłumaczenia tych zasobów, a więc plik np.: msg.resx kopiujemy i zmieniamy mu nazwę na msg.en-US.resx, a następnie tłumaczymy.
Teraz zabieramy się za XAML. Nadajemy kontrolką UID'y, wyciągamy zasoby do pliku CSV, tłumaczymy i ponownie używamy narzędzie LocBaml do wygenerowania dll'ki z zasobami. Wszystko poszło jak po maśle i zadowoleni chcemy skopiować świeżutką dll'ke z przetłumaczonymi zasobami do katalogu en-US, a tu figa z makiem. W katalogu znajduje się już dll'ka o takiej nazwie. Skąd się wzięła? Została wygenerowana przez VS i zawiera komunikaty, które umieściliśmy w pliku msg.en-US.resx. Mamy więc dwie dll'ki o takiej samej nazwie, jedną z przetłumaczonymi komunikatami, a drugą z przetłumaczonym GUI i musimy umieścić je w tym samym katalogu.
I tutaj zaczynają się schody, wysokie, wąskie, ciemne i niewygodne. Nie pozostaje nic innego jak użyć linkera al.exe. Da się to zrobić ale tak jak powiedziałem jest to bardzo niewygodne. Nie będę tutaj tego opisywał bo moim zdaniem jest to strasznie nudne. Jeśli ktoś tego potrzebuje to zapraszam do kontaktu.
Inne
Takie podejście do lokalizowania aplikacji WPF ma również inne wady. Tak jak wspomniałem wersja zasobów jaka zostanie wczytana zależy od właściwości Thread.CurrentThread.CurrentUICulture. Trzeba więc pamiętać aby ustawić ją na odpowiednią kulturę zanim zaczniemy robić cokolwiek z interfejsem użytkownika. W przeciwnym wypadku możemy doprowadzić do wczytania złej wersji zasobów i okaże się, że cześć okien będzie w języku polskim, a część w angielskim. Po drugie, modyfikacja Thread.CurrentThread.CurrentUICulture nie powoduje automatycznego przełączenia się pomiędzy jedną, a drugą wersją zasobów. Dopiero zamknięcie okna i ponowne jego wyświetlenie spowoduje pobranie odpowiedniej wersji językowej zasobów.Podsumowanie
LocBaml można użyć, pytanie czy z wszystkimi wadami i ograniczeniami tego narzędzia warto. W omawianym przypadku skończyło się to użyciem wyszukanej przez kolegę biblioteki WPFLocalizeExtension. Też ma swoje wady, też nie jest idealna, w szczególności w żaden sposób nie automatyzuje procesu lokalizowania aplikacji ale nie wymaga UID'ów, nie trzeba pisać skryptów, linkować itd. Tutaj zresztą kłania się to co już pisałem dwa razy:Jeśli chcemy aby nasza aplikacja miała wiele wersji językowych to przygotowujmy się do tego od pierwszej linijki tej aplikacji.