# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
###############################################################################
# OpenLP - Open Source Lyrics Projection #
# --------------------------------------------------------------------------- #
# Copyright (c) 2008-2017 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; version 2 of the License. #
# #
# 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, write to the Free Software Foundation, Inc., 59 #
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
import re
from string import Template
from PyQt5 import QtGui, QtCore, QtWebKitWidgets
from openlp.core.common import Registry, RegistryProperties, OpenLPMixin, RegistryMixin, Settings
from openlp.core.lib import FormattingTags, ImageSource, ItemCapabilities, ScreenList, ServiceItem, expand_tags, \
build_lyrics_format_css, build_lyrics_outline_css, build_chords_css
from openlp.core.common import ThemeLevel
from openlp.core.ui import MainDisplay
VERSE = 'The Lord said to {r}Noah{/r}: \n' \
'There\'s gonna be a {su}floody{/su}, {sb}floody{/sb}\n' \
'The Lord said to {g}Noah{/g}:\n' \
'There\'s gonna be a {st}floody{/st}, {it}floody{/it}\n' \
'Get those children out of the muddy, muddy \n' \
'{r}C{/r}{b}h{/b}{bl}i{/bl}{y}l{/y}{g}d{/g}{pk}' \
'r{/pk}{o}e{/o}{pp}n{/pp} of the Lord\n'
VERSE_FOR_LINE_COUNT = '\n'.join(map(str, range(100)))
FOOTER = ['Arky Arky (Unknown)', 'Public Domain', 'CCLI 123456']
[docs]class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties):
"""
Class to pull all Renderer interactions into one place. The plugins will call helper methods to do the rendering but
this class will provide display defense code.
"""
def __init__(self):
"""
Initialise the renderer.
"""
super(Renderer, self).__init__(None)
# Need live behaviour if this is also working as a pseudo MainDisplay.
self.screens = ScreenList()
self.theme_level = ThemeLevel.Global
self.global_theme_name = ''
self.service_theme_name = ''
self.item_theme_name = ''
self.force_page = False
self._theme_dimensions = {}
self._calculate_default()
self.web = QtWebKitWidgets.QWebView()
self.web.setVisible(False)
self.web_frame = self.web.page().mainFrame()
Registry().register_function('theme_update_global', self.set_global_theme)
[docs] def bootstrap_initialise(self):
"""
Initialise functions
"""
self.display = MainDisplay(self)
self.display.setup()
[docs] def update_display(self):
"""
Updates the renderer's information about the current screen.
"""
self._calculate_default()
if self.display:
self.display.close()
self.display = MainDisplay(self)
self.display.setup()
self._theme_dimensions = {}
[docs] def update_theme(self, theme_name, old_theme_name=None, only_delete=False):
"""
This method updates the theme in ``_theme_dimensions`` when a theme has been edited or renamed.
:param theme_name: The current theme name.
:param old_theme_name: The old theme name. Has only to be passed, when the theme has been renamed.
Defaults to *None*.
:param only_delete: Only remove the given ``theme_name`` from the ``_theme_dimensions`` list. This can be
used when a theme is permanently deleted.
"""
if old_theme_name is not None and old_theme_name in self._theme_dimensions:
del self._theme_dimensions[old_theme_name]
if theme_name in self._theme_dimensions:
del self._theme_dimensions[theme_name]
if not only_delete and theme_name:
self._set_theme(theme_name)
def _set_theme(self, theme_name):
"""
Helper method to save theme names and theme data.
:param theme_name: The theme name
"""
self.log_debug("_set_theme with theme {theme}".format(theme=theme_name))
if theme_name not in self._theme_dimensions:
theme_data = self.theme_manager.get_theme_data(theme_name)
main_rect = self.get_main_rectangle(theme_data)
footer_rect = self.get_footer_rectangle(theme_data)
self._theme_dimensions[theme_name] = [theme_data, main_rect, footer_rect]
else:
theme_data, main_rect, footer_rect = self._theme_dimensions[theme_name]
# if No file do not update cache
if theme_data.background_filename:
self.image_manager.add_image(theme_data.background_filename,
ImageSource.Theme, QtGui.QColor(theme_data.background_border_color))
[docs] def pre_render(self, override_theme_data=None):
"""
Set up the theme to be used before rendering an item.
:param override_theme_data: The theme data should be passed, when we want to use our own theme data, regardless
of the theme level. This should for example be used in the theme manager. **Note**, this is **not** to
be mixed up with the ``set_item_theme`` method.
"""
# Just assume we use the global theme.
theme_to_use = self.global_theme_name
# The theme level is either set to Service or Item. Use the service theme if one is set. We also have to use the
# service theme, even when the theme level is set to Item, because the item does not necessarily have to have a
# theme.
if self.theme_level != ThemeLevel.Global:
# When the theme level is at Service and we actually have a service theme then use it.
if self.service_theme_name:
theme_to_use = self.service_theme_name
# If we have Item level and have an item theme then use it.
if self.theme_level == ThemeLevel.Song and self.item_theme_name:
theme_to_use = self.item_theme_name
if override_theme_data is None:
if theme_to_use not in self._theme_dimensions:
self._set_theme(theme_to_use)
theme_data, main_rect, footer_rect = self._theme_dimensions[theme_to_use]
else:
# Ignore everything and use own theme data.
theme_data = override_theme_data
main_rect = self.get_main_rectangle(override_theme_data)
footer_rect = self.get_footer_rectangle(override_theme_data)
self._set_text_rectangle(theme_data, main_rect, footer_rect)
return theme_data, self._rect, self._rect_footer
[docs] def set_theme_level(self, theme_level):
"""
Sets the theme level.
:param theme_level: The theme level to be used.
"""
self.theme_level = theme_level
[docs] def set_global_theme(self):
"""
Set the global-level theme name.
"""
global_theme_name = Settings().value('themes/global theme')
self._set_theme(global_theme_name)
self.global_theme_name = global_theme_name
[docs] def set_service_theme(self, service_theme_name):
"""
Set the service-level theme.
:param service_theme_name: The service level theme's name.
"""
self._set_theme(service_theme_name)
self.service_theme_name = service_theme_name
[docs] def set_item_theme(self, item_theme_name):
"""
Set the item-level theme. **Note**, this has to be done for each item we are rendering.
:param item_theme_name: The item theme's name.
"""
self.log_debug("set_item_theme with theme {theme}".format(theme=item_theme_name))
self._set_theme(item_theme_name)
self.item_theme_name = item_theme_name
[docs] def generate_preview(self, theme_data, force_page=False):
"""
Generate a preview of a theme.
:param theme_data: The theme to generated a preview for.
:param force_page: Flag to tell message lines per page need to be generated.
"""
# save value for use in format_slide
self.force_page = force_page
# build a service item to generate preview
service_item = ServiceItem()
if self.force_page:
# make big page for theme edit dialog to get line count
service_item.add_from_text(VERSE_FOR_LINE_COUNT)
else:
service_item.add_from_text(VERSE)
service_item.raw_footer = FOOTER
# if No file do not update cache
if theme_data.background_filename:
self.image_manager.add_image(
theme_data.background_filename, ImageSource.Theme, QtGui.QColor(theme_data.background_border_color))
theme_data, main, footer = self.pre_render(theme_data)
service_item.theme_data = theme_data
service_item.main = main
service_item.footer = footer
service_item.render(True)
if not self.force_page:
self.display.build_html(service_item)
raw_html = service_item.get_rendered_frame(0)
self.display.text(raw_html, False)
preview = self.display.preview()
return preview
self.force_page = False
def _calculate_default(self):
"""
Calculate the default dimensions of the screen.
"""
screen_size = self.screens.current['size']
self.width = screen_size.width()
self.height = screen_size.height()
self.screen_ratio = self.height / self.width
self.log_debug('_calculate default {size}, {ratio:f}'.format(size=screen_size, ratio=self.screen_ratio))
# 90% is start of footer
self.footer_start = int(self.height * 0.90)
[docs] def get_main_rectangle(self, theme_data):
"""
Calculates the placement and size of the main rectangle.
:param theme_data: The theme information
"""
if not theme_data.font_main_override:
return QtCore.QRect(10, 0, self.width - 20, self.footer_start)
else:
return QtCore.QRect(theme_data.font_main_x, theme_data.font_main_y,
theme_data.font_main_width - 1, theme_data.font_main_height - 1)
def _set_text_rectangle(self, theme_data, rect_main, rect_footer):
"""
Sets the rectangle within which text should be rendered.
:param theme_data: The theme data.
:param rect_main: The main text block.
:param rect_footer: The footer text block.
"""
self.log_debug('_set_text_rectangle {main} , {footer}'.format(main=rect_main, footer=rect_footer))
self._rect = rect_main
self._rect_footer = rect_footer
self.page_width = self._rect.width()
self.page_height = self._rect.height()
if theme_data.font_main_shadow:
self.page_width -= int(theme_data.font_main_shadow_size)
self.page_height -= int(theme_data.font_main_shadow_size)
# For the life of my I don't know why we have to completely kill the QWebView in order for the display to work
# properly, but we do. See bug #1041366 for an example of what happens if we take this out.
self.web = None
self.web = QtWebKitWidgets.QWebView()
self.web.setVisible(False)
self.web.resize(self.page_width, self.page_height)
self.web_frame = self.web.page().mainFrame()
# Adjust width and height to account for shadow. outline done in css.
html = Template("""<!DOCTYPE html><html><head><script>
function show_text(newtext) {
var main = document.getElementById('main');
main.innerHTML = newtext;
// We need to be sure that the page is loaded, that is why we
// return the element's height (even though we do not use the
// returned value).
return main.offsetHeight;
}
</script>
<style>
*{margin: 0; padding: 0; border: 0;}
#main {position: absolute; top: 0px; ${format_css} ${outline_css}} ${chords_css}
</style></head>
<body><div id="main"></div></body></html>""")
self.web.setHtml(html.substitute(format_css=build_lyrics_format_css(theme_data,
self.page_width,
self.page_height),
outline_css=build_lyrics_outline_css(theme_data),
chords_css=build_chords_css()))
self.empty_height = self.web_frame.contentsSize().height()
def _paginate_slide(self, lines, line_end):
"""
Figure out how much text can appear on a slide, using the current theme settings.
**Note:** The smallest possible "unit" of text for a slide is one line. If the line is too long it will be cut
off when displayed.
:param lines: The text to be fitted on the slide split into lines.
:param line_end: The text added after each line. Either ``' '`` or ``'<br>``.
"""
formatted = []
previous_html = ''
previous_raw = ''
separator = '<br>'
html_lines = list(map(expand_tags, lines))
# Text too long so go to next page.
if not self._text_fits_on_slide(separator.join(html_lines)):
html_text, previous_raw = self._binary_chop(
formatted, previous_html, previous_raw, html_lines, lines, separator, '')
else:
previous_raw = separator.join(lines)
formatted.append(previous_raw)
return formatted
def _paginate_slide_words(self, lines, line_end):
"""
Figure out how much text can appear on a slide, using the current theme settings.
**Note:** The smallest possible "unit" of text for a slide is one word. If one line is too long it will be
processed word by word. This is sometimes need for **bible** verses.
:param lines: The text to be fitted on the slide split into lines.
:param line_end: The text added after each line. Either ``' '`` or ``'<br>``. This is needed for **bibles**.
"""
formatted = []
previous_html = ''
previous_raw = ''
for line in lines:
line = line.strip()
html_line = expand_tags(line)
# Text too long so go to next page.
if not self._text_fits_on_slide(previous_html + html_line):
# Check if there was a verse before the current one and append it, when it fits on the page.
if previous_html:
if self._text_fits_on_slide(previous_html):
formatted.append(previous_raw)
previous_html = ''
previous_raw = ''
# Now check if the current verse will fit, if it does not we have to start to process the verse
# word by word.
if self._text_fits_on_slide(html_line):
previous_html = html_line + line_end
previous_raw = line + line_end
continue
# Figure out how many words of the line will fit on screen as the line will not fit as a whole.
raw_words = words_split(line)
html_words = list(map(expand_tags, raw_words))
previous_html, previous_raw = \
self._binary_chop(formatted, previous_html, previous_raw, html_words, raw_words, ' ', line_end)
else:
previous_html += html_line + line_end
previous_raw += line + line_end
formatted.append(previous_raw)
return formatted
def _binary_chop(self, formatted, previous_html, previous_raw, html_list, raw_list, separator, line_end):
"""
This implements the binary chop algorithm for faster rendering. This algorithm works line based (line by line)
and word based (word by word). It is assumed that this method is **only** called, when the lines/words to be
rendered do **not** fit as a whole.
:param formatted: The list to append any slides.
:param previous_html: The html text which is know to fit on a slide, but is not yet added to the list of
slides. (unicode string)
:param previous_raw: The raw text (with formatting tags) which is know to fit on a slide, but is not yet added
to the list of slides. (unicode string)
:param html_list: The elements which do not fit on a slide and needs to be processed using the binary chop.
The text contains html.
:param raw_list: The elements which do not fit on a slide and needs to be processed using the binary chop.
The elements can contain formatting tags.
:param separator: The separator for the elements. For lines this is ``'<br>'`` and for words this is ``' '``.
:param line_end: The text added after each "element line". Either ``' '`` or ``'<br>``. This is needed for
bibles.
"""
smallest_index = 0
highest_index = len(html_list) - 1
index = highest_index // 2
while True:
if not self._text_fits_on_slide(previous_html + separator.join(html_list[:index + 1]).strip()):
# We know that it does not fit, so change/calculate the new index and highest_index accordingly.
highest_index = index
index = index - (index - smallest_index) // 2
else:
smallest_index = index
index = index + (highest_index - index) // 2
# We found the number of words which will fit.
if smallest_index == index or highest_index == index:
index = smallest_index
text = previous_raw.rstrip('<br>') + separator.join(raw_list[:index + 1])
text, raw_tags, html_tags = get_start_tags(text)
formatted.append(text)
previous_html = ''
previous_raw = ''
# Stop here as the theme line count was requested.
if self.force_page:
Registry().execute('theme_line_count', index + 1)
break
else:
continue
# Check if the remaining elements fit on the slide.
if self._text_fits_on_slide(html_tags + separator.join(html_list[index + 1:]).strip()):
previous_html = html_tags + separator.join(html_list[index + 1:]).strip() + line_end
previous_raw = raw_tags + separator.join(raw_list[index + 1:]).strip() + line_end
break
else:
# The remaining elements do not fit, thus reset the indexes, create a new list and continue.
raw_list = raw_list[index + 1:]
raw_list[0] = raw_tags + raw_list[0]
html_list = html_list[index + 1:]
html_list[0] = html_tags + html_list[0]
smallest_index = 0
highest_index = len(html_list) - 1
index = highest_index // 2
return previous_html, previous_raw
def _text_fits_on_slide(self, text):
"""
Checks if the given ``text`` fits on a slide. If it does ``True`` is returned, otherwise ``False``.
:param text: The text to check. It may contain HTML tags.
"""
self.web_frame.evaluateJavaScript('show_text'
'("{text}")'.format(text=text.replace('\\', '\\\\').replace('\"', '\\\"')))
return self.web_frame.contentsSize().height() <= self.empty_height
[docs]def words_split(line):
"""
Split the slide up by word so can wrap better
:param line: Line to be split
"""
# this parse we are to be wordy
return re.split(r'\s+', line)