#!/usr/bin/python

# Copyright 2005 Ben Hutchings <ben@decadentplace.org.uk>
# See "copyright" file for licence terms.

def usage():
    print """\
make-menu-vob generates VOB files suitable for use as DVD menus.

The following command line arguments are required:

-w frame-width -h frame-height
    Dimensions of the video frame
-m menu-specification
    File containing specification of the menu (described below)
-b background
    MPEG-2 file containing background video and optionally audio
-o output-file
    Name of the file to generate
-n normal-colour -i highlight-colour -s select-colour
    Colours to use for text in various states
-f font-file
    TrueType font file containing font to render text with

The specification contains lines in the format:
    t x y size text...
Where: t is a type code B, C or T
       x, y are the coordinates of the bottom left of the text
       size is the ascent of the text
       text is the text to be rendered
Type T creates static text.
Type B creates a button (selectable text with an associated action).
Type C specifies text to be combined into the next button.
Multi-line buttons are defined by one or more C lines followed by
a B line.

The specification may also include blank lines and comments prefixed
by a '#'.
"""

def prepare_image(width, height, fg_colour_text, bg_colour_text):
    '''Create a GD image with black (but transparent) background and a
    colour index allocated to the specified foreground and background
    colours.
    Return the image, foreground colour index, and background colour
    index.
    The background colour may be None, in which case the returned index will
    also be None.'''
    import gd
    image = gd.image((width, height))
    black = image.colorAllocate((0,0,0))
    image.colorTransparent(black)
    image.rectangle((0,0), (width-1,height-1), black, black)
    try:
        fg_r, fg_g, fg_b = map(int, fg_colour_text.split(','))
    except ValueError:
        raise Exception('invalid colour specification: ' + fg_colour_text)
    if bg_colour_text is not None:
        try:
            bg_r, bg_g, bg_b = map(int, bg_colour_text.split(','))
        except ValueError:
            raise Exception('invalid colour specification: ' + bg_colour_text)
    return (image,
            image.colorAllocate((fg_r, fg_g, fg_b)),
            bg_colour_text and image.colorAllocate((bg_r, bg_g, bg_b)))

class TemporaryDirectory:
    """Class of temporary directory proxies.  Its instances each
    create a directory when created and delete it (and its contents)
    when they are deleted.  The directory's path is made available
    through the path attribute."""
    def __init__(self):
        import errno, os, shutil, tempfile
        self._rmtree = shutil.rmtree
        while 1:
            self.path = tempfile.mktemp()
            try:
                os.mkdir(self.path)
                break
            except OSError, e:
                if e.errno == errno.EEXIST:
                    continue
                raise
    def __del__(self):
        self._rmtree(self.path, ignore_errors=1)

def shell_escape(arg):
    '''Return arg suitably escaped for use in a Bourne shell command-line.'''
    return "'" + arg.replace("'", "'\\''") + "'"

def xml_attr_escape(arg):
    '''Return arg suitably escaped for use as an XML attribute value.'''
    return "'" + arg.replace('&', '&amp;').replace('<', '&lt;').replace("'", '&apos;') + "'"

def main(args):

    import getopt, os, os.path

    try:
        opt_list, arg_list = getopt.getopt(args,
                                           'w:h:m:b:o:n:i:s:f:',
                                           ['xscale=', 'yscale=',
                                            'normal-background=',
                                            'highlight-background=',
                                            'select-background='])
        if len(arg_list) != 0:
            raise getopt.GetoptError('unexpected non-option arguments given')
        opts = {}
        for key, value in opt_list:
             opts[key] = value
        width = int(opts['-w'])
        height = int(opts['-h'])
        menu_spec = opts['-m']
        background_name = opts['-b']
        output_name = opts['-o']
        norm_fg_text = opts['-n']
        high_fg_text = opts['-i']
        sel_fg_text = opts['-s']
        font_name = opts['-f']
        xscale = float(opts.get('--xscale', 1.0))
        yscale = float(opts.get('--yscale', 1.0))
        norm_bg_text = opts.get('--normal-background')
        high_bg_text = opts.get('--highlight-background')
        sel_bg_text = opts.get('--select-background')
    except (getopt.GetoptError, KeyError, ValueError):
        usage()
        return 0

    norm_image, norm_fg, norm_bg = prepare_image(width, height, norm_fg_text, norm_bg_text)
    high_image, high_fg, high_bg = prepare_image(width, height, high_fg_text, high_bg_text)
    sel_image, sel_fg, sel_bg = prepare_image(width, height, sel_fg_text, sel_bg_text)

    temp_dir = TemporaryDirectory()
    sub_file_name = os.path.join(temp_dir.path, 'menu.sub')
    norm_image_file_name = os.path.join(temp_dir.path, 'normal.png')
    high_image_file_name = os.path.join(temp_dir.path, 'highlight.png')
    sel_image_file_name = os.path.join(temp_dir.path, 'select.png')

    sub_file = open(sub_file_name, 'w')
    sub_file.write("<subpictures>\n"
                   "<stream>\n"
                   "<spu force='yes' start='00:00:00.00'\n"
                   "     image=%s\n"
                   "     highlight=%s\n"
                   "     select=%s>\n"
                   % (xml_attr_escape(norm_image_file_name),
                      xml_attr_escape(high_image_file_name),
                      xml_attr_escape(sel_image_file_name)))

    button_texts = []

    for line in open(menu_spec):

        # Ignore blank lines and comment lines
        if line[0] in '#\n':
            continue

        args = line.strip().split(' ', 4)
        tcode, x, y, size, text = \
               args[0], int(args[1]), int(args[2]), int(args[3]), args[4]
        if tcode not in 'BCT':
            raise Exception('invalid menu entry type code: ' + tcode)

        # Fudge for supporting different TV standards: scale the dimensions.
        # Really we should apply both xscale and yscale to the font, but GD
        # appears to assume square pixels.
        size = int(size * yscale)
        x = int(x * xscale)
        y = int(y * yscale)

        # Get text bounding box and check that it's within the frame
        _, _, x1, y1, _, _, x0, y0 = \
           norm_image.get_bounding_rect(font_name, size, 0, (x, y), text)
        if x0 < 0 or y0 < 0 or x1 >= width or y1 >= height:
            raise Exception(
                'text "' + text + '" does not fit within the frame')

        # Ensure the background extends at least a quarter of the ascent to the
        # left and right of the text and similarly below the baseline and above
        # the top line.
        if norm_bg or high_bg or sel_bg:
            x0, x1 = x0 - size / 4, x1 + size / 4
            y0, y1 = min(y0, y - size * 5 / 4), max(y1, y + size / 4)
            if x0 < 0 or y0 < 0 or x1 >= width or y1 >= height:
                raise Exception(
                    'background for "' + text + '" does not fit within the frame')

        if tcode in 'BC':
            # Update button dimensions and content
            if button_texts:
                button_x0, button_y0 = min(button_x0, x0), min(button_y0, y0)
                button_x1, button_y1 = max(button_x1, x1), max(button_y1, y1)
            else:
                button_x0, button_y0, button_x1, button_y1 = x0, y0, x1, y1
            button_texts.append((size, x, y, text))

        # Determine which text to render and the background coordinates for it
        if tcode == 'B':
            texts = button_texts
            x0, y0, x1, y1 = button_x0, button_y0, button_x1, button_y1
        elif tcode == 'C':
            texts = []
        elif tcode == 'T':
            texts = [(size, x, y, text)]

        # Render background and text
        if texts:
            if norm_bg is not None:
                norm_image.rectangle((x0, y0), (x1, y1), norm_bg, norm_bg)
            if high_bg is not None:
                high_image.rectangle((x0, y0), (x1, y1), high_bg, high_bg)
            if sel_bg is not None:
                sel_image.rectangle((x0, y0), (x1, y1), sel_bg, sel_bg)
            for size, x, y, text in texts:
                # Note negation of the colour indices; this disables anti-
                # aliasing of the text, which unfortunately uses more colour
                # indices than we can spare.
                norm_image.string_ttf(font_name, size, 0, (x, y), text, -norm_fg)
                high_image.string_ttf(font_name, size, 0, (x, y), text, -high_fg)
                sel_image.string_ttf(font_name, size, 0, (x, y), text, -sel_fg)

        if tcode == 'B':
            # Finish processing of the button
            sub_file.write("<button x0='%d' y0='%d' x1='%d' y1='%d'/>"
                           % (button_x0, button_y0, button_x1, button_y1))
            button_texts = []

    if button_texts:
        raise Exception('Incomplete button at end of menu')

    sub_file.write("</spu>\n</stream>\n</subpictures>\n")
    sub_file.close()

    norm_image.writePng(norm_image_file_name)
    high_image.writePng(high_image_file_name)
    sel_image.writePng(sel_image_file_name)

    command = ("mplex -v0 -f 8 -o /dev/stdout %s"
               " | (spumux -v0 -m dvd %s 2>&1 >%s || rm -f %s)"
               " | egrep '(WARN|ERR)'"
               % (shell_escape(background_name),
                  shell_escape(sub_file_name),
                  shell_escape(output_name),
                  shell_escape(output_name)))
    print command
    os.system(command)

    # We don't get any useful result from os.system, so check whether
    # the output file was actually created.
    return os.path.isfile(output_name) 

if __name__ == '__main__':
    import sys
    sys.exit(1 - int(main(sys.argv[1:])))
