create_string_rc.py revision 5c02ac1a9c1b504631c0a3d2b6e737b5d738bae1
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
7create_string_rc.py" for usage details.
8
9This script generates an rc file and header (NAME.{rc,h}) to be included in
10setup.exe. The rc file includes translations for strings pulled from the given
11.grd file(s) and their corresponding localized .xtb files.
12
13The header file includes IDs for each string, but also has values to allow
14getting a string based on a language offset.  For example, the header file
15looks 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
28This allows us to lookup an an ID for a string by adding IDS_MY_STRING_BASE and
29IDS_L10N_OFFSET_* for the language we are interested in.
30"""
31
32import argparse
33import glob
34import io
35import os
36import sys
37from xml import sax
38
39BASEDIR = os.path.dirname(os.path.abspath(__file__))
40sys.path.append(os.path.join(BASEDIR, '../../../../tools/grit'))
41sys.path.append(os.path.join(BASEDIR, '../../../../tools/python'))
42
43from 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.
47STRING_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.
86FIRST_RESOURCE_ID = 1600
87
88
89class 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
153class 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
225class 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
397def 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
425def main():
426  args = ParseCommandLine()
427  StringRcMaker(args.name, args.inputs, args.outdir).MakeFiles()
428  return 0
429
430
431if '__main__' == __name__:
432  sys.exit(main())
433