# -*- coding: utf-8 -*- ########################################################################## # OpenLP - Open Source Lyrics Projection # # ---------------------------------------------------------------------- # # Copyright (c) 2008-2022 OpenLP Developers # # ---------------------------------------------------------------------- # # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details. # # # # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ########################################################################## """ Provide the theme XML and handling functions for OpenLP v2 themes. """ import json import logging import copy from lxml import etree, objectify from openlp.core.common import de_hump from openlp.core.common.applocation import AppLocation from openlp.core.common.json import OpenLPJSONDecoder, OpenLPJSONEncoder from openlp.core.display.screens import ScreenList from openlp.core.lib import get_text_file_string, str_to_bool, image_to_data_uri log = logging.getLogger(__name__) class BackgroundType(object): """ Type enumeration for backgrounds. """ Solid = 0 Gradient = 1 Image = 2 Transparent = 3 Video = 4 Stream = 5 @staticmethod def to_string(background_type): """ Return a string representation of a background type. """ if background_type == BackgroundType.Solid: return 'solid' elif background_type == BackgroundType.Gradient: return 'gradient' elif background_type == BackgroundType.Image: return 'image' elif background_type == BackgroundType.Transparent: return 'transparent' elif background_type == BackgroundType.Video: return 'video' elif background_type == BackgroundType.Stream: return 'stream' @staticmethod def from_string(type_string): """ Return a background type for the given string. """ if type_string == 'solid': return BackgroundType.Solid elif type_string == 'gradient': return BackgroundType.Gradient elif type_string == 'image': return BackgroundType.Image elif type_string == 'transparent': return BackgroundType.Transparent elif type_string == 'video': return BackgroundType.Video elif type_string == 'stream': return BackgroundType.Stream class BackgroundGradientType(object): """ Type enumeration for background gradients. """ Horizontal = 0 Vertical = 1 Circular = 2 LeftTop = 3 LeftBottom = 4 @staticmethod def to_string(gradient_type): """ Return a string representation of a background gradient type. """ if gradient_type == BackgroundGradientType.Horizontal: return 'horizontal' elif gradient_type == BackgroundGradientType.Vertical: return 'vertical' elif gradient_type == BackgroundGradientType.Circular: return 'circular' elif gradient_type == BackgroundGradientType.LeftTop: return 'leftTop' elif gradient_type == BackgroundGradientType.LeftBottom: return 'leftBottom' @staticmethod def from_string(type_string): """ Return a background gradient type for the given string. """ if type_string == 'horizontal': return BackgroundGradientType.Horizontal elif type_string == 'vertical': return BackgroundGradientType.Vertical elif type_string == 'circular': return BackgroundGradientType.Circular elif type_string == 'leftTop': return BackgroundGradientType.LeftTop elif type_string == 'leftBottom': return BackgroundGradientType.LeftBottom class TransitionType(object): """ Type enumeration for transition types. """ Fade = 0 Slide = 1 Convex = 2 Concave = 3 Zoom = 4 @staticmethod def to_string(transition_type): """ Return a string representation of a transition type. """ if transition_type == TransitionType.Fade: return 'fade' elif transition_type == TransitionType.Slide: return 'slide' elif transition_type == TransitionType.Convex: return 'convex' elif transition_type == TransitionType.Concave: return 'concave' elif transition_type == TransitionType.Zoom: return 'zoom' @staticmethod def from_string(type_string): """ Return a transition type for the given string. """ if type_string == 'fade': return TransitionType.Fade elif type_string == 'slide': return TransitionType.Slide elif type_string == 'convex': return TransitionType.Convex elif type_string == 'concave': return TransitionType.Concave elif type_string == 'zoom': return TransitionType.Zoom class TransitionSpeed(object): """ Type enumeration for transition types. """ Normal = 0 Fast = 1 Slow = 2 @staticmethod def to_string(transition_speed): """ Return a string representation of a transition type. """ if transition_speed == TransitionSpeed.Normal: return 'normal' elif transition_speed == TransitionSpeed.Fast: return 'fast' elif transition_speed == TransitionSpeed.Slow: return 'slow' @staticmethod def from_string(type_string): """ Return a transition type for the given string. """ if type_string == 'normal': return TransitionSpeed.Normal if type_string == 'fast': return TransitionSpeed.Fast elif type_string == 'slow': return TransitionSpeed.Slow class TransitionDirection(object): """ Type enumeration for transition types. """ Horizontal = 0 Vertical = 1 @staticmethod def to_string(transition_direction): """ Return a string representation of a transition type. """ if transition_direction == TransitionDirection.Horizontal: return 'horizontal' elif transition_direction == TransitionDirection.Vertical: return 'vertical' @staticmethod def from_string(type_string): """ Return a transition type for the given string. """ if type_string == 'horizontal': return TransitionDirection.Horizontal if type_string == 'vertical': return TransitionDirection.Vertical class HorizontalType(object): """ Type enumeration for horizontal alignment. """ Left = 0 Right = 1 Center = 2 Justify = 3 Names = ['left', 'right', 'center', 'justify'] @staticmethod def to_string(align): """ Return a string representation of the alignment """ return HorizontalType.Names[align] @staticmethod def from_string(align): """ Return an alignment for a given string """ return HorizontalType.Names.index(align) class VerticalType(object): """ Type enumeration for vertical alignment. """ Top = 0 Middle = 1 Bottom = 2 Names = ['top', 'middle', 'bottom'] @staticmethod def to_string(align): """ Return a string representation of the alignment """ return VerticalType.Names[align] @staticmethod def from_string(align): """ Return an alignment for a given string """ return VerticalType.Names.index(align) BOOLEAN_LIST = ['bold', 'italics', 'override', 'outline', 'shadow', 'slide_transition', 'slide_transition_reverse'] INTEGER_LIST = ['size', 'line_adjustment', 'x', 'height', 'y', 'width', 'shadow_size', 'outline_size', 'horizontal_align', 'vertical_align', 'wrap_style', 'slide_transition_type', 'slide_transition_speed', 'slide_transition_direction'] class Theme(object): """ A class to encapsulate the Theme XML. """ def __init__(self): """ Initialise the theme object. """ # basic theme object with defaults json_path = AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'lib' / 'json' / 'theme.json' jsn = get_text_file_string(json_path) self.load_theme(jsn) self.set_default_header_footer() self.background_filename = None self.background_source = None self.version = 2 def expand_json(self, var, prev=None): """ Expand the json objects and make into variables. :param var: The array list to be processed. :param prev: The preceding string to add to the key to make the variable. """ for key, value in var.items(): if prev: key = prev + "_" + key if isinstance(value, dict): self.expand_json(value, key) else: setattr(self, key, value) def extend_image_filename(self, path): """ Add the path name to the image name so the background can be rendered. :param pathlib.Path path: The path name to be added. :rtype: None """ if self.background_type == 'image' or self.background_type == 'video': if self.background_filename and path: self.theme_name = self.theme_name.strip() self.background_filename = path / self.theme_name / self.background_filename def set_default_header_footer(self): """ Set the default header and footer size to match the current primary screen. Obeys theme override variables. """ if not self.font_main_override: self.set_default_header() if not self.font_footer_override: self.set_default_footer() def set_default_header(self): """ Sets the default header position and size, ignores font_main_override """ current_screen_geometry = ScreenList().current.display_geometry self.font_main_x = 10 self.font_main_y = 0 self.font_main_width = current_screen_geometry.width() - 20 self.font_main_height = current_screen_geometry.height() * 9 / 10 def set_default_footer(self): """ Sets the default footer position and size, ignores font_footer_override """ current_screen_geometry = ScreenList().current.display_geometry self.font_footer_x = 10 self.font_footer_y = current_screen_geometry.height() * 9 / 10 self.font_footer_width = current_screen_geometry.width() - 20 self.font_footer_height = current_screen_geometry.height() / 10 def load_theme(self, theme, theme_path=None): """ Convert the JSON file and expand it. :param theme: the theme string :param pathlib.Path theme_path: The path to the theme :rtype: None """ if theme_path: jsn = json.loads(theme, cls=OpenLPJSONDecoder, base_path=theme_path) else: jsn = json.loads(theme, cls=OpenLPJSONDecoder) self.expand_json(jsn) def export_theme(self, theme_path=None, is_js=False): """ Loop through the fields and build a dictionary of them :param pathlib.Path | None theme_path: :param bool is_js: For internal use, for example with the theme js code. :return str: The json encoded theme object """ theme_data = {} for attr, value in self.__dict__.items(): theme_data["{attr}".format(attr=attr)] = value return json.dumps(theme_data, cls=OpenLPJSONEncoder, base_path=theme_path, is_js=is_js) def export_theme_self_contained(self, is_js=True): """ Get a self contained theme dictionary Same as export theme, but images is turned into a data uri :param is_js: For internal use, for example with the theme js code. :return str: The json encoded theme object """ theme_copy = copy.deepcopy(self) if self.background_type == 'image': image = image_to_data_uri(self.background_filename) theme_copy.background_filename = image current_screen_geometry = ScreenList().current.display_geometry theme_copy.display_size_width = current_screen_geometry.width() theme_copy.display_size_height = current_screen_geometry.height() theme_copy.background_source = '' exported_theme = theme_copy.export_theme(is_js=is_js) return exported_theme def parse(self, xml): """ Read in an XML string and parse it. :param xml: The XML string to parse. """ self.parse_xml(str(xml)) def parse_xml(self, xml): """ Parse an XML string. :param xml: The XML string to parse. """ # remove encoding string line = xml.find('?>') if line: xml = xml[line + 2:] try: theme_xml = objectify.fromstring(xml) except etree.XMLSyntaxError: log.exception('Invalid xml {text}'.format(text=xml)) return xml_iter = theme_xml.getiterator() for element in xml_iter: master = '' if element.tag == 'background': if element.attrib: for attr in element.attrib: self._create_attr(element.tag, attr, element.attrib[attr]) parent = element.getparent() if parent is not None: if parent.tag == 'font': master = parent.tag + '_' + parent.attrib['type'] # set up Outline and Shadow Tags and move to font_main if parent.tag == 'display': if element.tag.startswith('shadow') or element.tag.startswith('outline'): self._create_attr('font_main', element.tag, element.text) master = parent.tag if parent.tag == 'background': master = parent.tag if master: self._create_attr(master, element.tag, element.text) if element.attrib: for attr in element.attrib: base_element = attr # correction for the shadow and outline tags if element.tag == 'shadow' or element.tag == 'outline': if not attr.startswith(element.tag): base_element = element.tag + '_' + attr self._create_attr(master, base_element, element.attrib[attr]) else: if element.tag == 'name': self._create_attr('theme', element.tag, element.text) @staticmethod def _translate_tags(master, element, value): """ Clean up XML removing and redefining tags """ master = master.strip().lstrip() element = element.strip().lstrip() value = str(value).strip().lstrip() if master == 'display': if element == 'wrapStyle': return True, None, None, None if element.startswith('shadow') or element.startswith('outline'): master = 'font_main' # fix bold font ret_value = None if element == 'weight': element = 'bold' if value == 'Normal': ret_value = False else: ret_value = True if element == 'proportion': element = 'size' return False, master, element, ret_value if ret_value is not None else value def _create_attr(self, master, element, value): """ Create the attributes with the correct data types and name format """ reject, master, element, value = self._translate_tags(master, element, value) if reject: return field = de_hump(element) tag = master + '_' + field if field in BOOLEAN_LIST: setattr(self, tag, str_to_bool(value)) elif field in INTEGER_LIST: setattr(self, tag, int(value)) else: # make string value unicode if not isinstance(value, str): value = str(value, 'utf-8') # None means an empty string so lets have one. if value == 'None': value = '' setattr(self, tag, str(value).strip().lstrip()) def __str__(self): """ Return a string representation of this object. """ theme_strings = [] for key in dir(self): if key[0:1] != '_': theme_strings.append('{key:>30}: {value}'.format(key=key, value=getattr(self, key))) return '\n'.join(theme_strings)