1 #!/usr/bin/env python 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. 3 # Use of this source code is governed by a BSD-style license that can be 4 # found in the LICENSE file. 5 6 """Generates .h and .rc files for installer strings. Run "python 7 create_string_rc.py" for usage details. 8 9 This script generates an rc file and header (NAME.{rc,h}) to be included in 10 setup.exe. The rc file includes translations for strings pulled from the given 11 .grd file(s) and their corresponding localized .xtb files. 12 13 The header file includes IDs for each string, but also has values to allow 14 getting a string based on a language offset. For example, the header file 15 looks like this: 16 17 #define IDS_L10N_OFFSET_AR 0 18 #define IDS_L10N_OFFSET_BG 1 19 #define IDS_L10N_OFFSET_CA 2 20 ... 21 #define IDS_L10N_OFFSET_ZH_TW 41 22 23 #define IDS_MY_STRING_AR 1600 24 #define IDS_MY_STRING_BG 1601 25 ... 26 #define IDS_MY_STRING_BASE IDS_MY_STRING_AR 27 28 This allows us to lookup an an ID for a string by adding IDS_MY_STRING_BASE and 29 IDS_L10N_OFFSET_* for the language we are interested in. 30 """ 31 32 import argparse 33 import glob 34 import io 35 import os 36 import sys 37 from xml import sax 38 39 BASEDIR = os.path.dirname(os.path.abspath(__file__)) 40 sys.path.append(os.path.join(BASEDIR, '../../../../tools/grit')) 41 sys.path.append(os.path.join(BASEDIR, '../../../../tools/python')) 42 43 from grit.extern import tclib 44 45 # The IDs of strings we want to import from the .grd files and include in 46 # setup.exe's resources. 47 STRING_IDS = [ 48 'IDS_PRODUCT_NAME', 49 'IDS_SXS_SHORTCUT_NAME', 50 'IDS_PRODUCT_APP_LAUNCHER_NAME', 51 'IDS_PRODUCT_BINARIES_NAME', 52 'IDS_PRODUCT_DESCRIPTION', 53 'IDS_UNINSTALL_CHROME', 54 'IDS_ABOUT_VERSION_COMPANY_NAME', 55 'IDS_INSTALL_HIGHER_VERSION', 56 'IDS_INSTALL_HIGHER_VERSION_APP_LAUNCHER', 57 'IDS_INSTALL_FAILED', 58 'IDS_SAME_VERSION_REPAIR_FAILED', 59 'IDS_SETUP_PATCH_FAILED', 60 'IDS_INSTALL_OS_NOT_SUPPORTED', 61 'IDS_INSTALL_OS_ERROR', 62 'IDS_INSTALL_TEMP_DIR_FAILED', 63 'IDS_INSTALL_UNCOMPRESSION_FAILED', 64 'IDS_INSTALL_INVALID_ARCHIVE', 65 'IDS_INSTALL_INSUFFICIENT_RIGHTS', 66 'IDS_INSTALL_NO_PRODUCTS_TO_UPDATE', 67 'IDS_INSTALL_MULTI_INSTALLATION_EXISTS', 68 'IDS_INSTALL_INCONSISTENT_UPDATE_POLICY', 69 'IDS_OEM_MAIN_SHORTCUT_NAME', 70 'IDS_SHORTCUT_TOOLTIP', 71 'IDS_SHORTCUT_NEW_WINDOW', 72 'IDS_APP_LAUNCHER_PRODUCT_DESCRIPTION', 73 'IDS_APP_LAUNCHER_SHORTCUT_TOOLTIP', 74 'IDS_UNINSTALL_APP_LAUNCHER', 75 'IDS_APP_LIST_SHORTCUT_NAME', 76 'IDS_APP_LIST_SHORTCUT_NAME_CANARY', 77 'IDS_APP_SHORTCUTS_SUBDIR_NAME', 78 'IDS_APP_SHORTCUTS_SUBDIR_NAME_CANARY', 79 'IDS_INBOUND_MDNS_RULE_NAME', 80 'IDS_INBOUND_MDNS_RULE_NAME_CANARY', 81 'IDS_INBOUND_MDNS_RULE_DESCRIPTION', 82 'IDS_INBOUND_MDNS_RULE_DESCRIPTION_CANARY', 83 ] 84 85 # The ID of the first resource string. 86 FIRST_RESOURCE_ID = 1600 87 88 89 class GrdHandler(sax.handler.ContentHandler): 90 """Extracts selected strings from a .grd file. 91 92 Attributes: 93 messages: A dict mapping string identifiers to their corresponding messages. 94 """ 95 def __init__(self, string_ids): 96 """Constructs a handler that reads selected strings from a .grd file. 97 98 The dict attribute |messages| is populated with the strings that are read. 99 100 Args: 101 string_ids: A list of message identifiers to extract. 102 """ 103 sax.handler.ContentHandler.__init__(self) 104 self.messages = {} 105 self.__id_set = set(string_ids) 106 self.__message_name = None 107 self.__element_stack = [] 108 self.__text_scraps = [] 109 self.__characters_callback = None 110 111 def startElement(self, name, attrs): 112 self.__element_stack.append(name) 113 if name == 'message': 114 self.__OnOpenMessage(attrs.getValue('name')) 115 116 def endElement(self, name): 117 popped = self.__element_stack.pop() 118 assert popped == name 119 if name == 'message': 120 self.__OnCloseMessage() 121 122 def characters(self, content): 123 if self.__characters_callback: 124 self.__characters_callback(self.__element_stack[-1], content) 125 126 def __IsExtractingMessage(self): 127 """Returns True if a message is currently being extracted.""" 128 return self.__message_name is not None 129 130 def __OnOpenMessage(self, message_name): 131 """Invoked at the start of a <message> with message's name.""" 132 assert not self.__IsExtractingMessage() 133 self.__message_name = (message_name if message_name in self.__id_set 134 else None) 135 if self.__message_name: 136 self.__characters_callback = self.__OnMessageText 137 138 def __OnMessageText(self, containing_element, message_text): 139 """Invoked to handle a block of text for a message.""" 140 if message_text and (containing_element == 'message' or 141 containing_element == 'ph'): 142 self.__text_scraps.append(message_text) 143 144 def __OnCloseMessage(self): 145 """Invoked at the end of a message.""" 146 if self.__IsExtractingMessage(): 147 self.messages[self.__message_name] = ''.join(self.__text_scraps).strip() 148 self.__message_name = None 149 self.__text_scraps = [] 150 self.__characters_callback = None 151 152 153 class XtbHandler(sax.handler.ContentHandler): 154 """Extracts selected translations from an .xrd file. 155 156 Populates the |lang| and |translations| attributes with the language and 157 selected strings of an .xtb file. Instances may be re-used to read the same 158 set of translations from multiple .xtb files. 159 160 Attributes: 161 translations: A mapping of translation ids to strings. 162 lang: The language parsed from the .xtb file. 163 """ 164 def __init__(self, translation_ids): 165 """Constructs an instance to parse the given strings from an .xtb file. 166 167 Args: 168 translation_ids: a mapping of translation ids to their string 169 identifiers for the translations to be extracted. 170 """ 171 sax.handler.ContentHandler.__init__(self) 172 self.lang = None 173 self.translations = None 174 self.__translation_ids = translation_ids 175 self.__element_stack = [] 176 self.__string_id = None 177 self.__text_scraps = [] 178 self.__characters_callback = None 179 180 def startDocument(self): 181 # Clear the lang and translations since a new document is being parsed. 182 self.lang = '' 183 self.translations = {} 184 185 def startElement(self, name, attrs): 186 self.__element_stack.append(name) 187 # translationbundle is the document element, and hosts the lang id. 188 if len(self.__element_stack) == 1: 189 assert name == 'translationbundle' 190 self.__OnLanguage(attrs.getValue('lang')) 191 if name == 'translation': 192 self.__OnOpenTranslation(attrs.getValue('id')) 193 194 def endElement(self, name): 195 popped = self.__element_stack.pop() 196 assert popped == name 197 if name == 'translation': 198 self.__OnCloseTranslation() 199 200 def characters(self, content): 201 if self.__characters_callback: 202 self.__characters_callback(self.__element_stack[-1], content) 203 204 def __OnLanguage(self, lang): 205 self.lang = lang.replace('-', '_').upper() 206 207 def __OnOpenTranslation(self, translation_id): 208 assert self.__string_id is None 209 self.__string_id = self.__translation_ids.get(translation_id) 210 if self.__string_id is not None: 211 self.__characters_callback = self.__OnTranslationText 212 213 def __OnTranslationText(self, containing_element, message_text): 214 if message_text and containing_element == 'translation': 215 self.__text_scraps.append(message_text) 216 217 def __OnCloseTranslation(self): 218 if self.__string_id is not None: 219 self.translations[self.__string_id] = ''.join(self.__text_scraps).strip() 220 self.__string_id = None 221 self.__text_scraps = [] 222 self.__characters_callback = None 223 224 225 class StringRcMaker(object): 226 """Makes .h and .rc files containing strings and translations.""" 227 def __init__(self, name, inputs, outdir): 228 """Constructs a maker. 229 230 Args: 231 name: The base name of the generated files (e.g., 232 'installer_util_strings'). 233 inputs: A list of (grd_file, xtb_dir) pairs containing the source data. 234 outdir: The directory into which the files will be generated. 235 """ 236 self.name = name 237 self.inputs = inputs 238 self.outdir = outdir 239 240 def MakeFiles(self): 241 translated_strings = self.__ReadSourceAndTranslatedStrings() 242 self.__WriteRCFile(translated_strings) 243 self.__WriteHeaderFile(translated_strings) 244 245 class __TranslationData(object): 246 """A container of information about a single translation.""" 247 def __init__(self, resource_id_str, language, translation): 248 self.resource_id_str = resource_id_str 249 self.language = language 250 self.translation = translation 251 252 def __cmp__(self, other): 253 """Allow __TranslationDatas to be sorted by id then by language.""" 254 id_result = cmp(self.resource_id_str, other.resource_id_str) 255 return cmp(self.language, other.language) if id_result == 0 else id_result 256 257 def __ReadSourceAndTranslatedStrings(self): 258 """Reads the source strings and translations from all inputs.""" 259 translated_strings = [] 260 for grd_file, xtb_dir in self.inputs: 261 # Get the name of the grd file sans extension. 262 source_name = os.path.splitext(os.path.basename(grd_file))[0] 263 # Compute a glob for the translation files. 264 xtb_pattern = os.path.join(os.path.dirname(grd_file), xtb_dir, 265 '%s*.xtb' % source_name) 266 translated_strings.extend( 267 self.__ReadSourceAndTranslationsFrom(grd_file, glob.glob(xtb_pattern))) 268 translated_strings.sort() 269 return translated_strings 270 271 def __ReadSourceAndTranslationsFrom(self, grd_file, xtb_files): 272 """Reads source strings and translations for a .grd file. 273 274 Reads the source strings and all available translations for the messages 275 identified by STRING_IDS. The source string is used where translations are 276 missing. 277 278 Args: 279 grd_file: Path to a .grd file. 280 xtb_files: List of paths to .xtb files. 281 282 Returns: 283 An unsorted list of __TranslationData instances. 284 """ 285 sax_parser = sax.make_parser() 286 287 # Read the source (en-US) string from the .grd file. 288 grd_handler = GrdHandler(STRING_IDS) 289 sax_parser.setContentHandler(grd_handler) 290 sax_parser.parse(grd_file) 291 source_strings = grd_handler.messages 292 293 # Manually put the source strings as en-US in the list of translated 294 # strings. 295 translated_strings = [] 296 for string_id, message_text in source_strings.iteritems(): 297 translated_strings.append(self.__TranslationData(string_id, 298 'EN_US', 299 message_text)) 300 301 # Generate the message ID for each source string to correlate it with its 302 # translations in the .xtb files. 303 translation_ids = { 304 tclib.GenerateMessageId(message_text): string_id 305 for (string_id, message_text) in source_strings.iteritems() 306 } 307 308 # Gather the translated strings from the .xtb files. Use the en-US string 309 # for any message lacking a translation. 310 xtb_handler = XtbHandler(translation_ids) 311 sax_parser.setContentHandler(xtb_handler) 312 for xtb_filename in xtb_files: 313 sax_parser.parse(xtb_filename) 314 for string_id, message_text in source_strings.iteritems(): 315 translated_string = xtb_handler.translations.get(string_id, 316 message_text) 317 translated_strings.append(self.__TranslationData(string_id, 318 xtb_handler.lang, 319 translated_string)) 320 return translated_strings 321 322 def __WriteRCFile(self, translated_strings): 323 """Writes a resource file with the strings provided in |translated_strings|. 324 """ 325 HEADER_TEXT = ( 326 u'#include "%s.h"\n\n' 327 u'STRINGTABLE\n' 328 u'BEGIN\n' 329 ) % self.name 330 331 FOOTER_TEXT = ( 332 u'END\n' 333 ) 334 335 with io.open(os.path.join(self.outdir, self.name + '.rc'), 336 mode='w', 337 encoding='utf-16', 338 newline='\n') as outfile: 339 outfile.write(HEADER_TEXT) 340 for translation in translated_strings: 341 # Escape special characters for the rc file. 342 escaped_text = (translation.translation.replace('"', '""') 343 .replace('\t', '\\t') 344 .replace('\n', '\\n')) 345 outfile.write(u' %s "%s"\n' % 346 (translation.resource_id_str + '_' + translation.language, 347 escaped_text)) 348 outfile.write(FOOTER_TEXT) 349 350 def __WriteHeaderFile(self, translated_strings): 351 """Writes a .h file with resource ids.""" 352 # TODO(grt): Stream the lines to the file rather than building this giant 353 # list of lines first. 354 lines = [] 355 do_languages_lines = ['\n#define DO_LANGUAGES'] 356 installer_string_mapping_lines = ['\n#define DO_INSTALLER_STRING_MAPPING'] 357 358 # Write the values for how the languages ids are offset. 359 seen_languages = set() 360 offset_id = 0 361 for translation_data in translated_strings: 362 lang = translation_data.language 363 if lang not in seen_languages: 364 seen_languages.add(lang) 365 lines.append('#define IDS_L10N_OFFSET_%s %s' % (lang, offset_id)) 366 do_languages_lines.append(' HANDLE_LANGUAGE(%s, IDS_L10N_OFFSET_%s)' 367 % (lang.replace('_', '-').lower(), lang)) 368 offset_id += 1 369 else: 370 break 371 372 # Write the resource ids themselves. 373 resource_id = FIRST_RESOURCE_ID 374 for translation_data in translated_strings: 375 lines.append('#define %s %s' % (translation_data.resource_id_str + '_' + 376 translation_data.language, 377 resource_id)) 378 resource_id += 1 379 380 # Write out base ID values. 381 for string_id in STRING_IDS: 382 lines.append('#define %s_BASE %s_%s' % (string_id, 383 string_id, 384 translated_strings[0].language)) 385 installer_string_mapping_lines.append(' HANDLE_STRING(%s_BASE, %s)' 386 % (string_id, string_id)) 387 388 with open(os.path.join(self.outdir, self.name + '.h'), 'wb') as outfile: 389 outfile.write('\n'.join(lines)) 390 outfile.write('\n#ifndef RC_INVOKED') 391 outfile.write(' \\\n'.join(do_languages_lines)) 392 outfile.write(' \\\n'.join(installer_string_mapping_lines)) 393 # .rc files must end in a new line 394 outfile.write('\n#endif // ndef RC_INVOKED\n') 395 396 397 def ParseCommandLine(): 398 def GrdPathAndXtbDirPair(string): 399 """Returns (grd_path, xtb_dir) given a colon-separated string of the same. 400 """ 401 parts = string.split(':') 402 if len(parts) is not 2: 403 raise argparse.ArgumentTypeError('%r is not grd_path:xtb_dir') 404 return (parts[0], parts[1]) 405 406 parser = argparse.ArgumentParser( 407 description='Generate .h and .rc files for installer strings.') 408 parser.add_argument('-i', action='append', 409 type=GrdPathAndXtbDirPair, 410 required=True, 411 help='path to .grd file:relative path to .xtb dir', 412 metavar='GRDFILE:XTBDIR', 413 dest='inputs') 414 parser.add_argument('-o', 415 required=True, 416 help='output directory for generated .rc and .h files', 417 dest='outdir') 418 parser.add_argument('-n', 419 required=True, 420 help='base name of generated .rc and .h files', 421 dest='name') 422 return parser.parse_args() 423 424 425 def main(): 426 args = ParseCommandLine() 427 StringRcMaker(args.name, args.inputs, args.outdir).MakeFiles() 428 return 0 429 430 431 if '__main__' == __name__: 432 sys.exit(main()) 433