chromium/tools/grit/grit/tool/rc2grd.py

# Copyright 2012 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

'''The 'grit rc2grd' tool.'''


import os.path
import getopt
import re
import sys

from io import StringIO

import grit.node.empty
from grit.node import include
from grit.node import structure
from grit.node import message

from grit.gather import rc
from grit.gather import tr_html

from grit.tool import interface
from grit.tool import postprocess_interface
from grit.tool import preprocess_interface

from grit import grd_reader
from grit import lazy_re
from grit import tclib
from grit import util


# Matches files referenced from an .rc file
_FILE_REF = lazy_re.compile(r'''
  ^(?P<id>[A-Z_0-9.]+)[ \t]+
  (?P<type>[A-Z_0-9]+)[ \t]+
  "(?P<file>.*?([^"]|""))"[ \t]*$''', re.VERBOSE | re.MULTILINE)


# Matches a dialog section
_DIALOG = lazy_re.compile(
    r'^(?P<id>[A-Z0-9_]+)\s+DIALOG(EX)?\s.+?^BEGIN\s*$.+?^END\s*$',
    re.MULTILINE | re.DOTALL)


# Matches a menu section
_MENU = lazy_re.compile(r'^(?P<id>[A-Z0-9_]+)\s+MENU.+?^BEGIN\s*$.+?^END\s*$',
                        re.MULTILINE | re.DOTALL)


# Matches a versioninfo section
_VERSIONINFO = lazy_re.compile(
    r'^(?P<id>[A-Z0-9_]+)\s+VERSIONINFO\s.+?^BEGIN\s*$.+?^END\s*$',
    re.MULTILINE | re.DOTALL)


# Matches a stringtable
_STRING_TABLE = lazy_re.compile(
    (r'^STRINGTABLE(\s+(PRELOAD|DISCARDABLE|CHARACTERISTICS.+|LANGUAGE.+|'
     r'VERSION.+))*\s*\nBEGIN\s*$(?P<body>.+?)^END\s*$'),
    re.MULTILINE | re.DOTALL)


# Matches each message inside a stringtable, breaking it up into comments,
# the ID of the message, and the (RC-escaped) message text.
_MESSAGE = lazy_re.compile(r'''
  (?P<comment>(^\s+//.+?)*)  # 0 or more lines of comments preceding the message
  ^\s*
  (?P<id>[A-Za-z0-9_]+)  # id
  \s+
  "(?P<text>.*?([^"]|""))"([^"]|$)  # The message itself
  ''', re.MULTILINE | re.DOTALL | re.VERBOSE)


# Matches each line of comment text in a multi-line comment.
_COMMENT_TEXT = lazy_re.compile(r'^\s*//\s*(?P<text>.+?)$', re.MULTILINE)


# Matches a string that is empty or all whitespace
_WHITESPACE_ONLY = lazy_re.compile(r'\A\s*\Z', re.MULTILINE)


# Finds printf and FormatMessage style format specifiers
# Uses non-capturing groups except for the outermost group, so the output of
# re.split() should include both the normal text and what we intend to
# replace with placeholders.
# TODO(joi) Check documentation for printf (and Windows variants) and FormatMessage
_FORMAT_SPECIFIER = lazy_re.compile(
  r'(%[-# +]?(?:[0-9]*|\*)(?:\.(?:[0-9]+|\*))?(?:h|l|L)?' # printf up to last char
  r'(?:d|i|o|u|x|X|e|E|f|F|g|G|c|r|s|ls|ws)'              # printf last char
  r'|\$[1-9][0-9]*)')                                     # FormatMessage


class Rc2Grd(interface.Tool):
  '''A tool for converting .rc files to .grd files.  This tool is only for
converting the source (nontranslated) .rc file to a .grd file.  For importing
existing translations, use the rc2xtb tool.

Usage:  grit [global options] rc2grd [OPTIONS] RCFILE

The tool takes a single argument, which is the path to the .rc file to convert.
It outputs a .grd file with the same name in the same directory as the .rc file.
The .grd file may have one or more TODO comments for things that have to be
cleaned up manually.

OPTIONS may be any of the following:

  -e ENCODING    Specify the ENCODING of the .rc file. Default is 'cp1252'.

  -h TYPE        Specify the TYPE attribute for HTML structures.
                 Default is 'tr_html'.

  -u ENCODING    Specify the ENCODING of HTML files. Default is 'utf-8'.

  -n MATCH       Specify the regular expression to match in comments that will
                 indicate that the resource the comment belongs to is not
                 translateable. Default is 'Not locali(s|z)able'.

  -r GRDFILE     Specify that GRDFILE should be used as a "role model" for
                 any placeholders that otherwise would have had TODO names.
                 This attempts to find an identical message in the GRDFILE
                 and uses that instead of the automatically placeholderized
                 message.

  --pre CLASS    Specify an optional, fully qualified classname, which
                 has to be a subclass of grit.tool.PreProcessor, to
                 run on the text of the RC file before conversion occurs.
                 This can be used to support constructs in the RC files
                 that GRIT cannot handle on its own.

  --post CLASS   Specify an optional, fully qualified classname, which
                 has to be a subclass of grit.tool.PostProcessor, to
                 run on the text of the converted RC file.
                 This can be used to alter the content of the RC file
                 based on the conversion that occured.

For menus, dialogs and version info, the .grd file will refer to the original
.rc file.  Once conversion is complete, you can strip the original .rc file
of its string table and all comments as these will be available in the .grd
file.

Note that this tool WILL NOT obey C preprocessor rules, so even if something
is #if 0-ed out it will still be included in the output of this tool
Therefore, if your .rc file contains sections like this, you should run the
C preprocessor on the .rc file or manually edit it before using this tool.
'''

  def ShortDescription(self):
    return 'A tool for converting .rc source files to .grd files.'

  def __init__(self):
    self.input_encoding = 'cp1252'
    self.html_type = 'tr_html'
    self.html_encoding = 'utf-8'
    self.not_localizable_re = re.compile('Not locali(s|z)able')
    self.role_model = None
    self.pre_process = None
    self.post_process = None

  def ParseOptions(self, args, help_func=None):
    '''Given a list of arguments, set this object's options and return
    all non-option arguments.
    '''
    (own_opts, args) = getopt.getopt(args, 'e:h:u:n:r',
                                     ('help', 'pre=', 'post='))
    for (key, val) in own_opts:
      if key == '-e':
        self.input_encoding = val
      elif key == '-h':
        self.html_type = val
      elif key == '-u':
        self.html_encoding = val
      elif key == '-n':
        self.not_localizable_re = re.compile(val)
      elif key == '-r':
        self.role_model = grd_reader.Parse(val)
      elif key == '--pre':
        self.pre_process = val
      elif key == '--post':
        self.post_process = val
      elif key == '--help':
        if help_func is None:
          self.ShowUsage()
        else:
          help_func()
        sys.exit(0)
    return args

  def Run(self, opts, args):
    args = self.ParseOptions(args)
    if len(args) != 1:
      print('This tool takes a single tool-specific argument, the path to the\n'
            '.rc file to process.')
      return 2
    self.SetOptions(opts)

    path = args[0]
    out_path = os.path.join(util.dirname(path),
                os.path.splitext(os.path.basename(path))[0] + '.grd')

    rctext = util.ReadFile(path, self.input_encoding)
    grd_text = str(self.Process(rctext, path))
    with util.WrapOutputStream(open(out_path, 'wb'), 'utf-8') as outfile:
      outfile.write(grd_text)

    print('Wrote output file %s.\nPlease check for TODO items in the file.' %
          (out_path,))


  def Process(self, rctext, rc_path):
    '''Processes 'rctext' and returns a resource tree corresponding to it.

    Args:
      rctext: complete text of the rc file
      rc_path: 'resource\resource.rc'

    Return:
      grit.node.base.Node subclass
    '''

    if self.pre_process:
      preprocess_class = util.NewClassInstance(self.pre_process,
                                               preprocess_interface.PreProcessor)
      if preprocess_class:
        rctext = preprocess_class.Process(rctext, rc_path)
      else:
        self.Out(
          'PreProcessing class could not be found. Skipping preprocessing.\n')

    # Start with a basic skeleton for the .grd file
    root = grd_reader.Parse(StringIO(
      '''<?xml version="1.0" encoding="UTF-8"?>
      <grit base_dir="." latest_public_release="0"
          current_release="1" source_lang_id="en">
        <outputs />
        <translations />
        <release seq="1">
          <includes />
          <structures />
          <messages />
        </release>
      </grit>'''), util.dirname(rc_path))
    includes = root.children[2].children[0]
    structures = root.children[2].children[1]
    messages = root.children[2].children[2]
    assert (isinstance(includes, grit.node.empty.IncludesNode) and
            isinstance(structures, grit.node.empty.StructuresNode) and
            isinstance(messages, grit.node.empty.MessagesNode))

    self.AddIncludes(rctext, includes)
    self.AddStructures(rctext, structures, os.path.basename(rc_path))
    self.AddMessages(rctext, messages)

    self.VerboseOut('Validating that all IDs are unique...\n')
    root.ValidateUniqueIds()
    self.ExtraVerboseOut('Done validating that all IDs are unique.\n')

    if self.post_process:
      postprocess_class = util.NewClassInstance(self.post_process,
                                                postprocess_interface.PostProcessor)
      if postprocess_class:
        root = postprocess_class.Process(rctext, rc_path, root)
      else:
        self.Out(
          'PostProcessing class could not be found. Skipping postprocessing.\n')

    return root


  def IsHtml(self, res_type, fname):
    '''Check whether both the type and file extension indicate HTML'''
    fext = fname.split('.')[-1].lower()
    return res_type == 'HTML' and fext in ('htm', 'html')


  def AddIncludes(self, rctext, node):
    '''Scans 'rctext' for included resources (e.g. BITMAP, ICON) and
    adds each included resource as an <include> child node of 'node'.'''
    for m in _FILE_REF.finditer(rctext):
      id = m.group('id')
      res_type = m.group('type').upper()
      fname = rc.Section.UnEscape(m.group('file'))
      assert fname.find('\n') == -1
      if not self.IsHtml(res_type, fname):
        self.VerboseOut('Processing %s with ID %s (filename: %s)\n' %
                        (res_type, id, fname))
        node.AddChild(include.IncludeNode.Construct(node, id, res_type, fname))


  def AddStructures(self, rctext, node, rc_filename):
    '''Scans 'rctext' for structured resources (e.g. menus, dialogs, version
    information resources and HTML templates) and adds each as a <structure>
    child of 'node'.'''
    # First add HTML includes
    for m in _FILE_REF.finditer(rctext):
      id = m.group('id')
      res_type = m.group('type').upper()
      fname = rc.Section.UnEscape(m.group('file'))
      if self.IsHtml(type, fname):
        node.AddChild(structure.StructureNode.Construct(
          node, id, self.html_type, fname, self.html_encoding))

    # Then add all RC includes
    def AddStructure(res_type, id):
      self.VerboseOut('Processing %s with ID %s\n' % (res_type, id))
      node.AddChild(structure.StructureNode.Construct(node, id, res_type,
                                                      rc_filename,
                                                      encoding=self.input_encoding))
    for m in _MENU.finditer(rctext):
      AddStructure('menu', m.group('id'))
    for m in _DIALOG.finditer(rctext):
      AddStructure('dialog', m.group('id'))
    for m in _VERSIONINFO.finditer(rctext):
      AddStructure('version', m.group('id'))


  def AddMessages(self, rctext, node):
    '''Scans 'rctext' for all messages in string tables, preprocesses them as
    much as possible for placeholders (e.g. messages containing $1, $2 or %s, %d
    type format specifiers get those specifiers replaced with placeholders, and
    HTML-formatted messages get run through the HTML-placeholderizer).  Adds
    each message as a <message> node child of 'node'.'''
    for tm in _STRING_TABLE.finditer(rctext):
      table = tm.group('body')
      for mm in _MESSAGE.finditer(table):
        comment_block = mm.group('comment')
        comment_text = []
        for cm in _COMMENT_TEXT.finditer(comment_block):
          comment_text.append(cm.group('text'))
        comment_text = ' '.join(comment_text)

        id = mm.group('id')
        text = rc.Section.UnEscape(mm.group('text'))

        self.VerboseOut('Processing message %s (text: "%s")\n' % (id, text))

        msg_obj = self.Placeholderize(text)

        # Messages that contain only placeholders do not need translation.
        is_translateable = False
        for item in msg_obj.GetContent():
          if isinstance(item, str):
            if not _WHITESPACE_ONLY.match(item):
              is_translateable = True

        if self.not_localizable_re.search(comment_text):
          is_translateable = False

        message_meaning = ''
        internal_comment = ''

        # If we have a "role model" (existing GRD file) and this node exists
        # in the role model, use the description, meaning and translateable
        # attributes from the role model.
        if self.role_model:
          role_node = self.role_model.GetNodeById(id)
          if role_node:
            is_translateable = role_node.IsTranslateable()
            message_meaning = role_node.attrs['meaning']
            comment_text = role_node.attrs['desc']
            internal_comment = role_node.attrs['internal_comment']

        # For nontranslateable messages, we don't want the complexity of
        # placeholderizing everything.
        if not is_translateable:
          msg_obj = tclib.Message(text=text)

        msg_node = message.MessageNode.Construct(node, msg_obj, id,
                                                 desc=comment_text,
                                                 translateable=is_translateable,
                                                 meaning=message_meaning)
        msg_node.attrs['internal_comment'] = internal_comment

        node.AddChild(msg_node)
        self.ExtraVerboseOut('Done processing message %s\n' % id)


  def Placeholderize(self, text):
    '''Creates a tclib.Message object from 'text', attempting to recognize
    a few different formats of text that can be automatically placeholderized
    (HTML code, printf-style format strings, and FormatMessage-style format
    strings).
    '''

    try:
      # First try HTML placeholderizing.
      # TODO(joi) Allow use of non-TotalRecall flavors of HTML placeholderizing
      msg = tr_html.HtmlToMessage(text, True)
      for item in msg.GetContent():
        if not isinstance(item, str):
          return msg  # Contained at least one placeholder, so we're done

      # HTML placeholderization didn't do anything, so try to find printf or
      # FormatMessage format specifiers and change them into placeholders.
      msg = tclib.Message()
      parts = _FORMAT_SPECIFIER.split(text)
      todo_counter = 1  # We make placeholder IDs 'TODO_0001' etc.
      for part in parts:
        if _FORMAT_SPECIFIER.match(part):
          msg.AppendPlaceholder(tclib.Placeholder(
            'TODO_%04d' % todo_counter, part, 'TODO'))
          todo_counter += 1
        elif part != '':
          msg.AppendText(part)

      if self.role_model and len(parts) > 1:  # there are TODO placeholders
        role_model_msg = self.role_model.UberClique().BestCliqueByOriginalText(
          msg.GetRealContent(), '')
        if role_model_msg:
          # replace wholesale to get placeholder names and examples
          msg = role_model_msg

      return msg
    except:
      print('Exception processing message with text "%s"' % text)
      raise