bdunagan

Brian Dunagan

March 15 2009
ibtool: Localization Made Easy

Supporting multiple languages in a Mac app, known as internationalization or localization, is very easy on Mac OS X. Before we dig into the exact process, I’ll briefly cover what makes up a Mac app. Although they appear to be atomic items in Finder, Mac applications are actually folders containing various resources. (Right-click on an application and select ‘Show Package Contents’ to explore one.) In the Contents/Resources folder, there are folders with .lproj extensions. For example, Calculator.app has English.lproj, French.lproj, zh_CN.lproj, and fifteen others. Each of these .lproj folders contains UI files (NIBs, compiled down from XIBs) and strings (InfoPlist.strings and Localizable.strings that NSLocalizedString leverages) translated into that specific language. By simply changing the language in System Preferences->International, I can switch an app from English to French. Very neat.

When I first write a Mac application, the Resources folder only has an English.lproj folder in it. And the only reason it does is Xcode 3.1.2 defaults to localizing MainMenu.xib and InfoPlist.strings. When I create a new XIB, it’s not localized; I can localize it by right-clicking on it, selecting ‘Get Info’, and clicking ‘Make File Localizable’.

<img class=”alignnone size-medium wp-image-129” title=”IB: “Make File Localizable”” src=”http://www.bdunagan.com/files/ib_make_file_localizable.png” />

And I can click ‘Add Localization’ to add a French localization for that XIB. (Interface Builder suggests ‘French’, ‘German’, etc, but Apple recommends using the two-letter ISO 639-1 spec.)

It duplicates the XIB into French.lproj, creating the folder if it doesn’t exist. This process sounds convenient, and it’s absolutely necessary as a way to store localization knowledge in the project file. But it’s only meant as a first step. If I have ten languages, I don’t want to maintain ten copies of every XIB; I want to maintain one copy of every XIB and ten files with localized strings. Luckily, Apple provides this slick workflow through the command-line program ibtool.

Go to Terminal, find or create a MainMenu.xib, and type

ibtool --generate-strings-file MainMenu.strings MainMenu.xib

ibtool will look through MainMenu.xib for every user-visible string and insert that string with an associated ObjectID into MainMenu.strings, essentially a dictionary of strings keyed by ObjectID. Even better, the tool sorts them by ObjectID, so versioned .strings files are nicely diff’able. I can easily see what strings are added, removed, or just changed. Let me repeat this because it’s so incredibly handy: .strings files diff well! Of course, these .strings files are unicode, so they are not grep’able. Below is an example of the output for a string:

/* Class="NSMenuItem"; title="Window"; ObjectID="19"; */
"19.title" = "Window";

That’s the obvious part. It seems easy to extract strings with IDs from a XIB. I can send these .strings files to a localizer for translation. Here’s the awesome part: The same tool lets me create a new XIB with the original XIB and a modified .strings file. Go through the MainMenu.strings file we just generated, and change all the strings to something different. Go back to Terminal, create French.lproj if it doesn’t exist, and type

ibtool --strings-file MainMenu.strings --write ../French.lproj/MainMenu.xib MainMenu.xib

A new XIB is created in French.lproj with same UI elements as English.lproj but with the new strings. I can use these same commands in scripts to automate extracting the .strings files and re-generating the localized XIBs. Below are my two Ruby scripts for these tasks, both MIT license. Let me know if you have any issues with them.

generate_xib_strings.rb

#!/usr/bin/ruby
# MIT license

# generate_xib_strings.rb
# This script creates/clobbers a *.strings file for every XIB file in path argument.
require 'FileUtils'

# Check for arguments.
if ARGV.length != 1
  puts "Usage: ruby generate_xib_strings.rb path_to_lproj"
  exit
end

# Get path argument and 'cd' to that path.
PATH = ARGV[0]
FileUtils.cd(PATH)

# Iterate through the current directory.
Dir.entries(".").each do |file|
  filename = file.slice(0,file.length-4)
  extension = file.slice(file.length-4,file.length)
  # Only deal with XIBs.
  if (extension == ".xib")
    command = "ibtool --generate-strings-file \"#{filename}.strings\" \"#{filename}.xib\""
    results = %x[#{command}]
    if results.length > 0
      puts "FAILURE: #{command}:\n#{results}"
    else
      puts "SUCCESS: #{filename}.strings"
    end
  end
end

localize_xibs.rb

#!/usr/bin/ruby
# MIT license

# localize_xibs.rb
# This script takes a set of localized strings and creates/clobbers existing localized XIBs
# using the current English.lproj XIBs.
require 'FileUtils'

# Check for arguments.
if ARGV.length != 2
  puts "Usage: ruby localize_xibs.rb path_to_project path_to_strings"
  exit
end

# Get path arguments and 'cd' to that project path.
SOURCE_XIB_FOLDER = "English.lproj"
PROJECT_PATH = ARGV[0]
STRINGS_PATH = ARGV[1]
FileUtils.cd(PROJECT_PATH)
FILES_TO_IGNORE = [".svn", "." ,"..", ".DS_Store"]

# Iterate through the current directory.
Dir.entries(STRINGS_PATH).each do |localized_folder|
  if (!FILES_TO_IGNORE.include?(localized_folder))
    puts "Generating localizations for #{localized_folder}"
    # Iterate over the .strings language folders.
    Dir.entries(STRINGS_PATH + "/" + localized_folder).each do |strings_file|
      if (!FILES_TO_IGNORE.include?(strings_file))
        filename = strings_file.slice(0,strings_file.length-8)
        source_xib = SOURCE_XIB_FOLDER + "/" + filename
        strings_path = STRINGS_PATH + "/" + localized_folder + "/" + strings_file

        # Each .strings file needs to create/clobber a localized XIB in that .lproj folder.
        command = "ibtool --strings-file \"#{strings_path}\" --write \"#{localized_folder}.lproj/#{filename}.xib\" \"#{source_xib}.xib\""
        results = %x[#{command}]
        if results.length > 0
          puts "FAILURE: #{command}:\n#{results}"
        else
          puts "SUCCESS: #{localized_folder}.lproj/#{filename}.xib"
        end
      end
    end
  end
end

An interesting datapoint is SproutCore, the web app technology MobileMe is built on, also uses .lproj folders for localization. Isolating UI and localization in this way is a fantastic design pattern, so I’m happy to see it extended to the web world. Frankly, I have no idea how other websites and web apps handle localization. Perhaps they have a similar method.

UPDATE: I just discovered (from a great iPhone SDK article) an excellent companion tool to ibtool: genstrings. As ibtool works with XIBs, genstrings works with .m files. The command line tool allows me to extract all the strings declared in NSLocalizedString into Localizable.strings, with their comments. Here is an example output from genstrings -o English.lproj/ Classes/*.m:

/* This is a comment to describe it */
"This is a localizable string from a .m file" = "This is a localizable string from a .m file";
UITextView in a UITableView on the iPhone Debugging Preference Panes in Xcode
LinkedIn GitHub Email