October 20, 2009

Pimp My Code, Part 17: Lost in Translations.

Introduction

If you’re developing commercial software, you’re going to want to sell it globally — for well-localized English-speaking software companies, for instance, I have seen between 20% and 25% of total revenue coming from non-English speaking countries. That’s, like, real money. You’ll want that.
Delicious Monster
Gross Sales by
Language of Country
English80.9%
German8.1%
French3.2%
Japanese2.6%
Unknown / Other2.2%
Dutch1.5%
Spanish0.9%
Norwegian0.8%
Italian0.5%
Danish0.5%
Swedish0.5%
Chinese0.3%
Portuguese0.3%
[Delicious Monster Oct09]
Omni Group
Upgrades by Language
English74.4%
German6.0%
French5.3%
Japanese5.0%
Spanish2.3%
Other2.3%
Italian1.9%
Dutch1.4%
Chinese0.7%
Portuguese0.4%
Norwegian0.4%
[Omni Group Oct09]

Note that the data from the above tables was gathered in different ways: Omni tracks what language a user has set in her preferences every time she upgrades any of her Omni software, whereas I tracked Delicious Monster’s total dollar sales by country, and then assigned each country a “primary” language. (For countries like Switzerland, obviously my approach is an approximation.)

Still, you can try to make some educated guesses from these two data sets, although obviously correlations aren’t necessarily causative when analyzing only two samples. But, for example, Omni has Japanese-language support and Japanese-only boxed versions of their products; their Japanese number is twice that of Delicious Monster, which doesn’t have a partnership like this (yet). On the flip side, I have a Norwegian translation in Delicious Library 2, whereas OmniGraffle does not; my Norwegian number is twice that of Omni’s.

My German number is a bit higher, which is surprising because in general Delicious Library doesn’t appear to have penetrated overseas as well as Omni has. Maybe Germans love to organize books? Maybe they love doodads, and they bought my optional Bluetooth scanner, which makes their gross sales number much higher? There’s clearly a whole post to be written about mining this data, but this isn’t that post.

Abstract

In this post I’m going to explain to you what internationalization and localization are, how Apple’s tools handle them by default, and the huge flaws in Apple’s approach. Then I’m going to provide you with the code and tools to do localization in a much, much easier way.

Then you’re going to think, “That will never work, because of blah!” and I’m going to respond, as if I can read your mind or I’ve already had this argument with a dozen developers, “It already did — I used these tools in Delicious Library and Delicious Library 2 and they've won three Apple Design Awards between them.”

And you’re going to think, “Stupid show-off… why does he keep mentioning those?” And I’m going to say, “Look, in this case I think it’s justified, because the Design Awards are given based largely on the interface of an application, and I’m merely trying to demonstrate that the compromises I had to make in interface design didn’t have a huge impact on the feel of…” and then we’ll go back and forth for a while in an imaginary argument while I try to convince you to do things my way, dammit, because I’m the daddy.

“Internationalization” vs. “Localization”

What is internationalization, and what is localization? Good question! I’m so glad you asked.

First, you should really read Apple’s documentation on it. I mean, seriously, this should ALWAYS be your first target of inquiry. Did you read it yet? How about now? Now?

Ok, fine… briefly, ‘internationalization’ is getting your app ready to be localized for different languages/countries, and ‘localization’ is the process of actually adding a particular language to your app. The two are obviously are very closely related (you can't localize until you internationalize, and there's no point in internationalizing if you're not going to actually do any localizations) but it's vital to understand they are two distinct steps.

For instance, as a developer, you don't need to speak German to get a German localization — you just need to do the internationalization part and find one friendly deutschophone to do the localization, and you're golden. (I mean literally golden, as in covered in gold — don't ignore the German market! It's 8% of my total sales!)

Apple's Localization Process

Ok, you’re sold on localization. But how do you proceed? Another good question! (Again, thank you for tossing me all these softballs.)

First off (as I’ve said many-a-time), when you are writing your source code you should surround EVERY string that the user might eventually see in the NSLocalizedString() macro (or a variant). This will enable you to later automatically pull the English phrases out of your app, for translation by people who actually, like, speak other languages. Errrr… whatchacallums. “Polyglots!”

But then what? Apple’s recommended process is:
  • You run Apple’s command-line tool, ‘genstrings’, over your source files (hopefully as part of your build process in Xcode), to pull out all the English phrases you marked with NSLocalizedString() and its cousins. See, I told you that’d be useful! And here’s it’s already paid off, only a couple paragraphs later!
  • You give the “.strings” files that genstrings generates, along with any XIBs and image files that contain English words, to a localizer, who is a native speaker of the language you want.
  • The localizer creates a new directory of translated “.strings” files, plus XIBs and images with English words translated as well.
  • You take this directory, and put in into a language bundle in your project, like “ja.lproj”.
Easy? Sort of. But you can already see problems:

Problem 1: Localizers Can’t Effectively Localize Images

You really really don’t want your localizers editing your image files. I mean, your artists spent a lot of time making your images really nice (since you’re not an idiot and you didn’t try to do your artwork yourself) and they used things like layers and paths and filters and stuff, and your localizers don’t know how to use Photoshop and aren’t artists, and furthermore you probably gave your localizers the final PNG or JPEG2000 files instead of PSDs and so they don’t have all those layers and paths and filters and they’d do a really crappy job if they even could do it which they can’t.

Also, your artists are going to keep tweaking the images right up until the day you ship (and after — and then they’re going to beg you to delay) and you could spend 100% of your time just sending out updated images to localizers for re-translation, and your localizers are either going to charge you a fortune or get pissed off and quit (depending on if you pay them or not, respectively).

Luckily, there’s a simple and obvious solution to this one: DO NOT EVER PUT WORDS IN YOUR IMAGES.

buyalbum.png
buybook.png
buysong.png
this is wrong, wrong, so wrong.

Yes, I KNOW Apple does it. I think it’s some bizarre hold-over from their Carbon days, when drawing actual styled text on a 3D button using actual source code was CRAZY TALK. Plus Apple has a huge in-house art staff, so it’s easy for engineers to say, “Hey, art dudes, give me a button in three different states that says, ‘Buy Album’ in our latest iTunes-only-style-that’s-not-quite-like-Cocoa’s-standards,” and the engineer is done for the day and didn’t have to write any code and can go suck down a mojito.

Never mind that every time Apple’s (separate-but-equal) iTunes UI team changes the look of their buttons, the artists have to redo several hundred images and then re-localize them, resulting in thousands of fiddly images. That’s certainly a lot more efficient than the programmer spending a couple hours writing code to draw the button border and inside and the shadow on the text, once, and then just modifying that code in one place whenever the look changes, right? RIGHT? (* see answer at end of post.)

Luckily, you don’t even have the option to do it this stupid way, because you don’t have an art department whose time you’d like to waste. So, use icons whenever possible on buttons, and if you must use text, then make it localizable. Luckily, this is pretty easy in Cocoa, since the button edges, insides, and the various shines and shadows are all done for you if you use the standard mechanisms, and they’re easy to subclass, once, if you want a custom look.

For button titles that require text, you can either type English words directly into Interface Builder, or if you’re not on an iPhone you can bind the button’s title some method in your code and use NSLocalizedString() to make sure it’s localized. Since you’re going to have to localize most of your XIBs anyhow (because of menu items, mouseover help text, explanatory text boxes, field titles, window names, etc.), I really only recommend the binding approach if your button’s title changes in a way you can’t model in Interface Builder itself. (That is, don’t write an extra method if you don’t have to. Less code is better.)

Problem 2: Localizers Can’t Effectively Localize XIBs

Asking your localizers to modify XIBs directly (using Interface Builder) is a huge pain: First, you limit your pool of potential localizers if they have to have the developer tools installed AND understand how to use them.

inspectorforlocalization.png
hope the localizer finds this "display pattern!"

Second, XIB files aren’t flat or self-documenting, so it’s VERY hard for localizers to find EVERY last English word in your XIB. Did they forget a tooltip? What about an alternate title for a button? What about a button’s title binding’s formatter string? Maybe there’s a hidden view somewhere? You’ll have to keep testing the localizations you get for complete coverage, and sending them back to be redone, and then re-testing them again. And again. Yuck.

Sure, there’s a cool command in Interface Builder now, where you can hit control-S and see all the strings in your interface — but that’s ANOTHER thing you have to teach each localizer to do. In addition to knowing how to use Interface Builder in the first place.

Third, XIBs are like source code: they are written by programmers and contain functional parts. If your localizers happen to delete a button, or disconnect a binding, your program stops working for that language. Remember, your localizers are NOT coders — they don’t have the same innate fear of changing XIBs that you’ve learned from years of boning yourself. And how fun is it to debug a program that works differently in different languages? Not fun.

Fourth, many language are not as compact as English: the French and Germans are particularly fond of using the descriptions with the lots of the words or compoundwordstodescribeasingleconcept, respectively. You’re asking your localizers to resize your buttons and titles, and then, if those don’t fit in their containers, resize your views and widgets and panels as well.

Let me restate that last point: you are asking your localizers to design your interface. If you’re making an application that catalogs users’ books, CDs, DVDs, and other physical media, then I urge you to go ahead and do this. For the rest of you, WHAT THE HELL ARE YOU THINKING?

Problem 3: Localizers Can’t Localize from Only Your App

In the old days, the NIBs we shipped were the same as the NIBs we used to build the app (and to localize), so any polyglot user in the world could just make a new language folder in our app wrapper by copying our English resources, and then start localizing the NIBs and .strings files, and at any time she could launch our app and check her progress. (Which is good, because it’s AMAZINGLY common for localizers to screw up the punctuation in .strings files, and Cocoa’s localization machinery will just silently ignore all strings after any punctuation mistake, resulting in mystery partial localizations.)

Sure, the user had to understand Interface Builder, but at least if she did, she had everything she needed.

Nowadays, Apple doesn’t want people mucking about CHANGING their programs, so we compile XIBs down into read-only NIBs, which means we have to give localizers our original XIB files (as well as our .strings) before they can start work.

Think about the difference between some user, somewhere, just deciding to start editing a folder full of files that she already has, versus her having to write you, you having to bundle up all your XIBs and .strings and send them to her, her having to edit them all without seeing ANY results while she is doing it, then bundle them up and send them back to you, then you have to integrate them into your build system and make a test release, and either look at it yourself or send it to her again.

Lather, rinse, repeat, lather, stab, stab, stab.

Even if this weren’t a horrible, time-wasting process (it is), this points to the final and biggest problem with localization:

Problem 4: You Have to Maintain Multiple Copies of Each Localized Resource

If you have nine translations and each has a localized copy of every XIB in your application, then you’ll have to manually maintain nine different versions of each XIB. The strings in XIBs don’t change super-often, but the connections, flags, layout, and other object properties change all the time. And every time you change a flag or layout or connection in one XIB, you have to do the same thing eight more times. And do it perfectly, or you’ll have a language-specific bug.

Of course, you can wait until all your XIBs are fully finished before you localize, but, if you’re like me, you are still fiddling with your XIBs until the day you ship your 1.0 code. So you’d have wait to localize until AFTER shipping 1.0, which stinks, or put off shipping until you’ve finished the product, sent out the localizations, AND gotten them all back — which stinks. And then you find have to change some buggy XIBs for your version 1.2, and you’d still have the exact same problem of having to edit nine XIBs to make one change.

(This is obviously also true of images that have been localized, but hopefully I’ve talked you out of EVER doing that, so I’m not going to discuss that further.)

Now, Apple has provided some tools in an attempt to make this all easier — they have something called “ibtool” (“nibtool” before Leopard) which can pull all the strings from a file, and then put new ones in their place, supposedly. In fact, it’s intended to allow you to look at the geometry and language changes between two localized versions of the same XIB, so you can generate, say, a Japanese version of your Main Menu nib with the latest changes from your English Main Menu nib, except keeping changes that you specifically made in the geometry of objects in the German version (since some words are longer and/or shorter in other languages, and thus XIBs sometimes require relayout).

Apple also has a tool called AppleGlot which sets up an entire “translation environment.” It apparently dates from Carbon days, and although it’s been updated to work with some versions of Interface Builder last time I tried it, it was a lot more trouble than value to me.

Finally, there are some third-party tools that attempt to simplify some of this: for example Polyglot, but it appears to have not been updated in a while, and I’m honestly not sure what all it does. (I’m providing the link so you can research it and other products if you want.)

I disagree with the entire approach of storing a bunch of geometry diffs to your XIBs — this seems INCREDIBLY fragile to me. What happens when you move a text field from one view to another? What happens if you even move views around? All those geometry changes turn to gooblity-gook.

The fundamental problem with every tool I’ve seen to fix your XIBs, however,is that you still have to maintain all the localized versions. You have nine extra copies of each XIB in your source code, you have to make sure they are all synced up, somehow.

Now, if I told you, “Look, it’s easy to localize your Objective-C files — just maintain ten copies of each! Every time you change one, you’ll have to change all the others… but don’t worry! I’ve provided some tools that kind of help with this… until they don’t…” What would you think?

You’d spit in my eye. And I don’t like spitty eyes. So, I came up with a better way, with exactly the same solution Apple used for Objective-C files.

An Almost Ideal Solution

Let’s design, in our heads, the perfect localization system, or as close as we can get without, you know, having to do a lot of work:

• First, we simply promise ourselves we won’t put words in image files. There, that solves a lot. This is usually Apple’s convention as well, although even certain Cocoa teams (-cough-iWeb-cough-) have used fully-rendered images of words like “PUBLISH” instead of using strings — hey, THAT’S not going to be resolution-independent, is it? (I keeed! I keeeeeeed!)

• For XIBS, what we’d like (as programmers) is to have and maintain ONE single English XIB, which will polymorph to the user’s chosen language at launch time, so we don’t have to ship with ten versions of the same XIB (they’re surprisingly big) or maintain ten versions of the same XIB in our source code (with the concomitant increase in errors as our XIB localizations slowly drift out of sync).

• If we (the programmers) change a string or the whole look of a XIB, we want partial localizations to still work — it’s fine with with us if sometimes we ship with one or two English words “peeking through” a localization, since in most countries (obviously not France) they mix in English words all the time anyhow. It’s certainly better to have a partial localization (as long as it is of high-quality) than either no localization or a corrupted one. (This goes against a programmer’s natural inclination to insist that solutions be provably perfect. Get used to it.)

• To make our localizers’ jobs easier, what we’d like to do is give them ONLY .strings files, so all localization takes is any text editor – localizers don’t have to know how to use Interface Builder or any developer tools.

• We’re forgetful, so we want to generate the strings files from our XIBs and source files every time we do a build, so we never give out-of-date versions to our localizers — if they have a build of the app, then they have the latest strings files, AND the means to test their localizations themselves.

• Since our app is going to ship with the complete set of .strings files needed to localize it, ANY customer who speaks another language can copy our English.lproj to TheirLanguage.lproj, edit the .strings files, and voila create a localized version of our app after a couple hours’ work.

Here's the Code

Now, as you might guess, I've already done this, and, if I may plug myself for a second, this code was already available to clients of Golden % Braeburn, which is my other company where I give my Delicious store source code (and other miscellaneous helpful code) to Mac developers for a paltry percentage of sales. (Yes, Golden % Braeburn has launched, and yes, one of Golden % Braeburn's clients, Acacia Tree Software, is currently selling its app with our store.)

The first step to slurp all the English strings out of your XIB files at build time, so your localizers have nice flat strings files to work with. Add two new build phases to your application target in Xcode using “Project ▶ New Build Phase ▶ New Run Script Build Phase”, set the shells to /bin/zsh, and have them look like this:

Internationalize Source Code Shell Script Build Phase

# -q silences duplicate comments with same key warning
genstrings -q -o ${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/${DEVELOPMENT_LANGUAGE}.lproj ${SRCROOT}/**/*.[hm]



Internationalize XIBs Shell Script Build Phase

foreach nibFile (${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/**/*.nib)
stringsFilePath=${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/${DEVELOPMENT_LANGUAGE}.lproj/`basename ${nibFile} .nib`.strings
xibFile=`basename ${nibFile} .nib`.xib
xibFilePath=`echo ${SOURCE_ROOT}/**/${xibFile}`
if [[ -e ${xibFilePath} ]] {
ibtool --generate-stringsfile ${stringsFilePath}~ ${xibFilePath}
${BUILT_PRODUCTS_DIR}/xibLocalizationPostprocessor ${stringsFilePath}~ ${stringsFilePath}
rm ${stringsFilePath}~
}
end


Now comes a slight hitch — under Leopard and beyond, the “ibtool” command idiotically outputs a new format of .strings file that looks like this:

/* Class = "NSButtonCell"; title = "Get iPhone & iPod Touch App";
ObjectID = "1401603"; */
"1401603.title" = "Get iPhone & iPod Touch App";

/* Class = "NSTextFieldCell"; title = "Remote Libraries:";
ObjectID = "1401604"; */
"1401604.title" = "Remote Libraries:";


Instead of the standard file format (output by the older “nibtool” and the current “genstrings”):

/* Class = "NSButtonCell"; title = "Get iPhone & iPod Touch App";
ObjectID = "1401603"; */
"Get iPhone & iPod Touch App" = "Get iPhone & iPod Touch App";

/* Class = "NSTextFieldCell"; title = "Remote Libraries:";
ObjectID = "1401604"; */
"Remote Libraries:" = "Remote Libraries:";


This new format completely violates the whole .strings file paradigm and obviously makes it impossible for us to use this to localize XIBs on-the-fly, because when we load XIBs on our own, we don't have those ObjectIDs so we can't match objects up to the strings they need. Nice going, Apple.

So, I wrote a little program to post-process “ibtool”s output to make it like “nibtool” and “genstrings”, you should add this code to your Xcode project and add a new “tool” target for it and make sure your main app depends on this tool being built, and all will be well:
xibLocalizationPostprocessor.m: Post-processing ibtool output
// xibLocalizationPostprocessor.m
//
// Created by William Shipley on 4/14/08.
// Copyright © 2005-2009 Golden % Braeburn, LLC.

#import <Cocoa/Cocoa.h>


int main(int argc, const char *argv[])
{
NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init]; {
if (argc != 3) {
fprintf(stderr, "Usage: %s inputfile outputfile\n", argv[0]);
exit (-1);
}

NSError *error = nil;
NSStringEncoding usedEncoding;
NSString *rawXIBStrings = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:argv[1]] usedEncoding:&usedEncoding error:&error];
if (error) {
fprintf(stderr, "Error reading %s: %s\n", argv[1], error.localizedDescription.UTF8String);
exit (-1);
}

NSMutableString *outputStrings = [NSMutableString string];
NSUInteger lineCount = 0;
NSString *lastComment = nil;
for (NSString *line in [rawXIBStrings componentsSeparatedByString:@"\n"]) {
lineCount++;

if ([line hasPrefix:@"/*"]) { // eg: /* Class = "NSMenuItem"; title = "Quit Library"; ObjectID = "136"; */
lastComment = line;
continue;

} else if (line.length == 0) {
lastComment = nil;
continue;

} else if ([line hasPrefix:@"\""] && [line hasSuffix:@"\";"]) { // eg: "136.title" = "Quit Library";

NSRange quoteEqualsQuoteRange = [line rangeOfString:@"\" = \""];
if (quoteEqualsQuoteRange.length && NSMaxRange(quoteEqualsQuoteRange) < line.length - 1) {
if (lastComment) {
[outputStrings appendString:lastComment];
[outputStrings appendString:@"\n"];
}
NSString *stringNeedingLocalization = [line substringFromIndex:NSMaxRange(quoteEqualsQuoteRange)]; // chop off leading: "blah" = "
stringNeedingLocalization = [stringNeedingLocalization substringToIndex:stringNeedingLocalization.length - 2]; // chop off trailing: ";
[outputStrings appendFormat:@"\"%@\" = \"%@\";\n\n", stringNeedingLocalization, stringNeedingLocalization];
continue;
}
}

NSLog(@"Warning: skipped garbage input line %d, contents: \"%@\"", lineCount, line);
}

if (outputStrings.length && ![outputStrings writeToFile:[NSString stringWithUTF8String:argv[2]] atomically:NO encoding:NSUTF8StringEncoding error:&error]) {
fprintf(stderr, "Error writing %s: %s\n", argv[2], error.localizedDescription.UTF8String);
exit (-1);
}
} [autoreleasePool release];
}


Finally, add this class to your main project. Whenever you load a XIB file now, this code will be invoked, and it’ll run through all the displayed strings in your XIB and see if there is a localization in the correspondingly-named strings file. (Eg, if you load “MainMenu.xib”, it’ll automatically be localized with strings from “MainMenu.strings”.)

DMLocalizedNibBundle.m: Run-time Localization of XIBs

// DMLocalizedNibBundle.m
//
// Created by William Jon Shipley on 2/13/05.
// Copyright © 2005-2009 Golden % Braeburn, LLC. All rights reserved except as below:
// This code is provided as-is, with no warranties or anything. You may use it in your projects as you wish, but you must leave this comment block (credits and copyright) intact. That's the only restriction -- Golden % Braeburn otherwise grants you a fully-paid, worldwide, transferrable license to use this code as you see fit, including but not limited to making derivative works.


#import <Cocoa/Cocoa.h>
#import <objc/runtime.h>


@interface NSBundle (DMLocalizedNibBundle)
+ (BOOL)deliciousLocalizingLoadNibFile:(NSString *)fileName externalNameTable:(NSDictionary *)context withZone:(NSZone *)zone;
@end

@interface NSBundle ()
+ (void)_localizeStringsInObject:(id)object table:(NSString *)table;
+ (NSString *)_localizedStringForString:(NSString *)string table:(NSString *)table;
// localize particular attributes in objects
+ (void)_localizeTitleOfObject:(id)object table:(NSString *)table;
+ (void)_localizeAlternateTitleOfObject:(id)object table:(NSString *)table;
+ (void)_localizeStringValueOfObject:(id)object table:(NSString *)table;
+ (void)_localizePlaceholderStringOfObject:(id)object table:(NSString *)table;
+ (void)_localizeToolTipOfObject:(id)object table:(NSString *)table;
@end


@implementation NSBundle (DMLocalizedNibBundle)

#pragma mark NSObject

+ (void)load;
{
NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init];
if (self == [NSBundle class]) {
method_exchangeImplementations(class_getClassMethod(self, @selector(loadNibFile:externalNameTable:withZone:)), class_getClassMethod(self, @selector(deliciousLocalizingLoadNibFile:externalNameTable:withZone:)));
}
[autoreleasePool release];
}


#pragma mark API

+ (BOOL)deliciousLocalizingLoadNibFile:(NSString *)fileName externalNameTable:(NSDictionary *)context withZone:(NSZone *)zone;
{
NSString *localizedStringsTableName = [[fileName lastPathComponent] stringByDeletingPathExtension];
NSString *localizedStringsTablePath = [[NSBundle mainBundle] pathForResource:localizedStringsTableName ofType:@"strings"];
if (localizedStringsTablePath && ![[[localizedStringsTablePath stringByDeletingLastPathComponent] lastPathComponent] isEqualToString:@"English.lproj"]) {

NSNib *nib = [[NSNib alloc] initWithContentsOfURL:[NSURL fileURLWithPath:fileName]];
NSMutableArray *topLevelObjectsArray = [context objectForKey:NSNibTopLevelObjects];
if (!topLevelObjectsArray) {
topLevelObjectsArray = [NSMutableArray array];
context = [NSMutableDictionary dictionaryWithDictionary:context];
[(NSMutableDictionary *)context setObject:topLevelObjectsArray forKey:NSNibTopLevelObjects];
}
BOOL success = [nib instantiateNibWithExternalNameTable:context];
[self _localizeStringsInObject:topLevelObjectsArray table:localizedStringsTableName];

[nib release];
return success;

} else {
return [self deliciousLocalizingLoadNibFile:fileName externalNameTable:context withZone:zone];
}
}



#pragma mark Private API

+ (void)_localizeStringsInObject:(id)object table:(NSString *)table;
{
if ([object isKindOfClass:[NSArray class]]) {
NSArray *array = object;

for (id nibItem in array)
[self _localizeStringsInObject:nibItem table:table];

} else if ([object isKindOfClass:[NSCell class]]) {
NSCell *cell = object;

if ([cell isKindOfClass:[NSActionCell class]]) {
NSActionCell *actionCell = (NSActionCell *)cell;

if ([actionCell isKindOfClass:[NSButtonCell class]]) {
NSButtonCell *buttonCell = (NSButtonCell *)actionCell;
if ([buttonCell imagePosition] != NSImageOnly) {
[self _localizeTitleOfObject:buttonCell table:table];
[self _localizeStringValueOfObject:buttonCell table:table];
[self _localizeAlternateTitleOfObject:buttonCell table:table];
}

} else if ([actionCell isKindOfClass:[NSTextFieldCell class]]) {
NSTextFieldCell *textFieldCell = (NSTextFieldCell *)actionCell;
// Following line is redundant with other code, localizes twice.
// [self _localizeTitleOfObject:textFieldCell table:table];
[self _localizeStringValueOfObject:textFieldCell table:table];
[self _localizePlaceholderStringOfObject:textFieldCell table:table];

} else if ([actionCell type] == NSTextCellType) {
[self _localizeTitleOfObject:actionCell table:table];
[self _localizeStringValueOfObject:actionCell table:table];
}
}

} else if ([object isKindOfClass:[NSMenu class]]) {
NSMenu *menu = object;
[self _localizeTitleOfObject:menu table:table];

[self _localizeStringsInObject:[menu itemArray] table:table];

} else if ([object isKindOfClass:[NSMenuItem class]]) {
NSMenuItem *menuItem = object;
[self _localizeTitleOfObject:menuItem table:table];

[self _localizeStringsInObject:[menuItem submenu] table:table];

} else if ([object isKindOfClass:[NSView class]]) {
NSView *view = object;
[self _localizeToolTipOfObject:view table:table];

if ([view isKindOfClass:[NSBox class]]) {
NSBox *box = (NSBox *)view;
[self _localizeTitleOfObject:box table:table];

} else if ([view isKindOfClass:[NSControl class]]) {
NSControl *control = (NSControl *)view;

if ([view isKindOfClass:[NSButton class]]) {
NSButton *button = (NSButton *)control;

if ([button isKindOfClass:[NSPopUpButton class]]) {
NSPopUpButton *popUpButton = (NSPopUpButton *)button;
NSMenu *menu = [popUpButton menu];

[self _localizeStringsInObject:[menu itemArray] table:table];
} else
[self _localizeStringsInObject:[button cell] table:table];


} else if ([view isKindOfClass:[NSMatrix class]]) {
NSMatrix *matrix = (NSMatrix *)control;

NSArray *cells = [matrix cells];
[self _localizeStringsInObject:cells table:table];

for (NSCell *cell in cells) {

NSString *localizedCellToolTip = [self _localizedStringForString:[matrix toolTipForCell:cell] table:table];
if (localizedCellToolTip)
[matrix setToolTip:localizedCellToolTip forCell:cell];
}

} else if ([view isKindOfClass:[NSSegmentedControl class]]) {
NSSegmentedControl *segmentedControl = (NSSegmentedControl *)control;

NSUInteger segmentIndex, segmentCount = [segmentedControl segmentCount];
for (segmentIndex = 0; segmentIndex < segmentCount; segmentIndex++) {
NSString *localizedSegmentLabel = [self _localizedStringForString:[segmentedControl labelForSegment:segmentIndex] table:table];
if (localizedSegmentLabel)
[segmentedControl setLabel:localizedSegmentLabel forSegment:segmentIndex];

[self _localizeStringsInObject:[segmentedControl menuForSegment:segmentIndex] table:table];
}

} else
[self _localizeStringsInObject:[control cell] table:table];

}

[self _localizeStringsInObject:[view subviews] table:table];

} else if ([object isKindOfClass:[NSWindow class]]) {
NSWindow *window = object;
[self _localizeTitleOfObject:window table:table];

[self _localizeStringsInObject:[window contentView] table:table];

}
}

+ (NSString *)_localizedStringForString:(NSString *)string table:(NSString *)table;
{
if (![string length])
return nil;

static NSString *defaultValue = @"I AM THE DEFAULT VALUE";
NSString *localizedString = [[NSBundle mainBundle] localizedStringForKey:string value:defaultValue table:table];
if (localizedString != defaultValue) {
return localizedString;
} else {
#ifdef BETA_BUILD
NSLog(@" not going to localize string %@", string);
return string; // [string uppercaseString]
#else
return string;
#endif
}
}


#define DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(blahName, capitalizedBlahName) \
+ (void)_localize ##capitalizedBlahName ##OfObject:(id)object table:(NSString *)table; \
{ \
NSString *localizedBlah = [self _localizedStringForString:[object blahName] table:table]; \
if (localizedBlah) \
[object set ##capitalizedBlahName:localizedBlah]; \
}

DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(title, Title)
DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(alternateTitle, AlternateTitle)
DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(stringValue, StringValue)
DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(placeholderString, PlaceholderString)
DM_DEFINE_LOCALIZE_BLAH_OF_OBJECT(toolTip, ToolTip)

@end

Unresolved Issues

Issue 1: Not all XIB properties are localized

I didn’t have any NSTableViews with standard column headers in Delicious Library, so there’s no code to handle NSTableViews at all in here. I also don’t even attempt to localize bindings strings, I believe. So, that’d be a good thing to do!

I haven’t touched this code in, like, four years. Which says a lot about how well it’s worked for me, but, obviously, there’s room for improving it. So, please pimp *my* code, and send it back to me, and I’ll post your changes. (We’ll all be better off for it!)

Issue 2: You can’t use the same phrase with two different meanings in a single XIB

Imagine if you have, say, a button labelled “Wind” for “what you do to a watch,” and a text field labelled “Wind” for “moving air.” You obviously can’t localize ‘em with this method. Hasn’t proven to be a big deal with me, but this is clearly what motivated Apple to completely hose string the file format for “ibtool.”

Issue 3: iPhone limitations

I never got around to porting this to work on the iPhone, since Amazon made me destroy the iPhone version of Delicious Library, but it’d be pretty easy to make this work with hierarchies of UIViews and UIControls instead of their NSCousins.

I don’t think the swap-methods-at-load technique will work on the iPhone, either, so you’d have to manually call the translation method when you loaded a XIB, but I don’t think that’s a huge deal, since on the iPhone you typically are MUCH more conscious of when you load and unload XIBs.

Also, obviously your customers couldn’t simply get the .strings from your app bundle or add their localizations themselves, so you lose those advantages. Still, this is the technique I’ll be using in my future iPhone and apps.

Issue 4: You can’t change the geometry of XIBs depending on the localization

The method requires that we, as programmers and interface designers, agree to make ALL our text fields and checkbuttons wide enough to allow for the wordiest of languages, since we’re localizing on-the-fly.

Everyone raises this as an objection, but, seriously, the alternatives are MUCH worse — either you maintain ten sets of geometries yourself, OR you let your localizers be your interface designers. Both are horrible.

And, seriously, you’d be surprised how little people notice when you have extra space. With checkboxes, just always stretch ‘em to the edge of the screen. With text fields — well, the user can’t tell HOW wide they are, can she? The only problem one is buttons, and I think those look better with extra space around them. (Update: my German localizer wrote me to tell me he disagreed with this — that it was a huge pain for him to find short enough German words. I apologized to him for not telling him he could request more width.)

I’ve even thought up two ways around this issue: one if obvious, which is you could just store some geometry changes based on the container type & string in a separate file, and merge those changes at run-time. But, that’d be hard to maintain, for reasons I’ve gone over a bunch by now.

The other would be cooler: have your widgets re-lay themselves out at run time based on their resizing properties if their localized titles don’t fit. For example, if button “a” has a button “b” 20 pixels to the right of it, and both buttons have the stretchy spring to their right, then it’s a fair guess that if you grow/shrink button “a”, you need to do so from its right edge, and you need to move “a” by that many pixels, as well. The other combinations of stretchy springs can be assigned values, and all you have to do is use them consistently in your XIBs and you’ll be golden. (You do have to watch for accidentally localizing Apple’s XIBs.)

But, seriously, I haven’t really done either of these yet, and no English speaker has ever said, “Hey, you have too much space around this button!”

And, importantly, I’m able to integrate all thirteen localizations of Delicious Library 2 by myself, and still have time to program and run my company and drink lots of mojitos.

Footnotes

*No, it isn't.

Labels: , ,