1
# -*- coding: utf-8 -*-
2
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
4
###############################################################################
5
# OpenLP - Open Source Lyrics Projection #
6
# --------------------------------------------------------------------------- #
7
# Copyright (c) 2008-2018 OpenLP Developers #
8
# --------------------------------------------------------------------------- #
9
# This program is free software; you can redistribute it and/or modify it #
10
# under the terms of the GNU General Public License as published by the Free #
11
# Software Foundation; version 2 of the License. #
13
# This program is distributed in the hope that it will be useful, but WITHOUT #
14
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
15
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
18
# You should have received a copy of the GNU General Public License along #
19
# with this program; if not, write to the Free Software Foundation, Inc., 59 #
20
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
21
###############################################################################
24
from string import Template
26
from PyQt5 import QtGui, QtCore, QtWebKitWidgets
28
from openlp.core.common import ThemeLevel
29
from openlp.core.common.mixins import LogMixin, RegistryProperties
30
from openlp.core.common.path import path_to_str
31
from openlp.core.common.registry import Registry, RegistryBase
32
from openlp.core.common.settings import Settings
33
from openlp.core.display.screens import ScreenList
34
from openlp.core.lib import FormattingTags, ImageSource, ItemCapabilities, ServiceItem, expand_tags, build_chords_css, \
35
build_lyrics_format_css, build_lyrics_outline_css
36
from openlp.core.ui.maindisplay import MainDisplay
38
VERSE = 'The Lord said to {r}Noah{/r}: \n' \
39
'There\'s gonna be a {su}floody{/su}, {sb}floody{/sb}\n' \
40
'The Lord said to {g}Noah{/g}:\n' \
41
'There\'s gonna be a {st}floody{/st}, {it}floody{/it}\n' \
42
'Get those children out of the muddy, muddy \n' \
43
'{r}C{/r}{b}h{/b}{bl}i{/bl}{y}l{/y}{g}d{/g}{pk}' \
44
'r{/pk}{o}e{/o}{pp}n{/pp} of the Lord\n'
45
VERSE_FOR_LINE_COUNT = '\n'.join(map(str, range(100)))
46
FOOTER = ['Arky Arky (Unknown)', 'Public Domain', 'CCLI 123456']
49
class Renderer(RegistryBase, LogMixin, RegistryProperties):
51
Class to pull all Renderer interactions into one place. The plugins will call helper methods to do the rendering but
52
this class will provide display defense code.
57
Initialise the renderer.
59
super(Renderer, self).__init__(None)
60
# Need live behaviour if this is also working as a pseudo MainDisplay.
61
self.screens = ScreenList()
62
self.theme_level = ThemeLevel.Global
63
self.global_theme_name = ''
64
self.service_theme_name = ''
65
self.item_theme_name = ''
66
self.force_page = False
67
self._theme_dimensions = {}
68
self._calculate_default()
69
self.web = QtWebKitWidgets.QWebView()
70
self.web.setVisible(False)
71
self.web_frame = self.web.page().mainFrame()
72
Registry().register_function('theme_update_global', self.set_global_theme)
74
def bootstrap_initialise(self):
78
self.display = MainDisplay(self)
81
def update_display(self):
83
Updates the renderer's information about the current screen.
85
self._calculate_default()
88
self.display = MainDisplay(self)
90
self._theme_dimensions = {}
92
def update_theme(self, theme_name, old_theme_name=None, only_delete=False):
94
This method updates the theme in ``_theme_dimensions`` when a theme has been edited or renamed.
96
:param theme_name: The current theme name.
97
:param old_theme_name: The old theme name. Has only to be passed, when the theme has been renamed.
99
:param only_delete: Only remove the given ``theme_name`` from the ``_theme_dimensions`` list. This can be
100
used when a theme is permanently deleted.
102
if old_theme_name is not None and old_theme_name in self._theme_dimensions:
103
del self._theme_dimensions[old_theme_name]
104
if theme_name in self._theme_dimensions:
105
del self._theme_dimensions[theme_name]
106
if not only_delete and theme_name:
107
self._set_theme(theme_name)
109
def _set_theme(self, theme_name):
111
Helper method to save theme names and theme data.
113
:param theme_name: The theme name
115
self.log_debug("_set_theme with theme {theme}".format(theme=theme_name))
116
if theme_name not in self._theme_dimensions:
117
theme_data = self.theme_manager.get_theme_data(theme_name)
118
main_rect = self.get_main_rectangle(theme_data)
119
footer_rect = self.get_footer_rectangle(theme_data)
120
self._theme_dimensions[theme_name] = [theme_data, main_rect, footer_rect]
122
theme_data, main_rect, footer_rect = self._theme_dimensions[theme_name]
123
# if No file do not update cache
124
if theme_data.background_filename:
125
self.image_manager.add_image(path_to_str(theme_data.background_filename),
126
ImageSource.Theme, QtGui.QColor(theme_data.background_border_color))
128
def pre_render(self, override_theme_data=None):
130
Set up the theme to be used before rendering an item.
132
:param override_theme_data: The theme data should be passed, when we want to use our own theme data, regardless
133
of the theme level. This should for example be used in the theme manager. **Note**, this is **not** to
134
be mixed up with the ``set_item_theme`` method.
136
# Just assume we use the global theme.
137
theme_to_use = self.global_theme_name
138
# The theme level is either set to Service or Item. Use the service theme if one is set. We also have to use the
139
# service theme, even when the theme level is set to Item, because the item does not necessarily have to have a
141
if self.theme_level != ThemeLevel.Global:
142
# When the theme level is at Service and we actually have a service theme then use it.
143
if self.service_theme_name:
144
theme_to_use = self.service_theme_name
145
# If we have Item level and have an item theme then use it.
146
if self.theme_level == ThemeLevel.Song and self.item_theme_name:
147
theme_to_use = self.item_theme_name
148
if override_theme_data is None:
149
if theme_to_use not in self._theme_dimensions:
150
self._set_theme(theme_to_use)
151
theme_data, main_rect, footer_rect = self._theme_dimensions[theme_to_use]
153
# Ignore everything and use own theme data.
154
theme_data = override_theme_data
155
main_rect = self.get_main_rectangle(override_theme_data)
156
footer_rect = self.get_footer_rectangle(override_theme_data)
157
self._set_text_rectangle(theme_data, main_rect, footer_rect)
158
return theme_data, self._rect, self._rect_footer
160
def set_theme_level(self, theme_level):
162
Sets the theme level.
164
:param theme_level: The theme level to be used.
166
self.theme_level = theme_level
168
def set_global_theme(self):
170
Set the global-level theme name.
172
global_theme_name = Settings().value('themes/global theme')
173
self._set_theme(global_theme_name)
174
self.global_theme_name = global_theme_name
176
def set_service_theme(self, service_theme_name):
178
Set the service-level theme.
180
:param service_theme_name: The service level theme's name.
182
self._set_theme(service_theme_name)
183
self.service_theme_name = service_theme_name
185
def set_item_theme(self, item_theme_name):
187
Set the item-level theme. **Note**, this has to be done for each item we are rendering.
189
:param item_theme_name: The item theme's name.
191
self.log_debug("set_item_theme with theme {theme}".format(theme=item_theme_name))
192
self._set_theme(item_theme_name)
193
self.item_theme_name = item_theme_name
195
def generate_preview(self, theme_data, force_page=False):
197
Generate a preview of a theme.
199
:param theme_data: The theme to generated a preview for.
200
:param force_page: Flag to tell message lines per page need to be generated.
201
:rtype: QtGui.QPixmap
203
# save value for use in format_slide
204
self.force_page = force_page
205
# build a service item to generate preview
206
service_item = ServiceItem()
208
# make big page for theme edit dialog to get line count
209
service_item.add_from_text(VERSE_FOR_LINE_COUNT)
211
service_item.add_from_text(VERSE)
212
service_item.raw_footer = FOOTER
213
# if No file do not update cache
214
if theme_data.background_filename:
215
self.image_manager.add_image(path_to_str(theme_data.background_filename),
216
ImageSource.Theme, QtGui.QColor(theme_data.background_border_color))
217
theme_data, main, footer = self.pre_render(theme_data)
218
service_item.theme_data = theme_data
219
service_item.main = main
220
service_item.footer = footer
221
service_item.render(True)
222
if not self.force_page:
223
self.display.build_html(service_item)
224
raw_html = service_item.get_rendered_frame(0)
225
self.display.text(raw_html, False)
226
return self.display.preview()
227
self.force_page = False
229
def format_slide(self, text, item):
231
Calculate how much text can fit on a slide.
233
:param text: The words to go on the slides.
234
:param item: The :class:`~openlp.core.lib.serviceitem.ServiceItem` item object.
237
self.log_debug('format slide')
238
# Add line endings after each line of text used for bibles.
240
if item.is_capable(ItemCapabilities.NoLineBreaks):
243
if item.is_capable(ItemCapabilities.CanWordSplit):
244
pages = self._paginate_slide_words(text.split('\n'), line_end)
246
elif item.is_capable(ItemCapabilities.CanSoftBreak):
249
# Remove Overflow split if at start of the text
250
if text.startswith('[---]'):
252
# Remove two or more option slide breaks next to each other (causing infinite loop).
253
while '\n[---]\n[---]\n' in text:
254
text = text.replace('\n[---]\n[---]\n', '\n[---]\n')
255
while ' [---]' in text:
256
text = text.replace(' [---]', '[---]')
257
while '[---] ' in text:
258
text = text.replace('[---] ', '[---]')
260
# only loop 5 times as there will never be more than 5 incorrect logical splits on a single slide.
261
while True and count < 5:
262
slides = text.split('\n[---]\n', 2)
263
# If there are (at least) two occurrences of [---] we use the first two slides (and neglect the last
266
html_text = expand_tags('\n'.join(slides[:2]))
267
# We check both slides to determine if the optional split is needed (there is only one optional
270
html_text = expand_tags('\n'.join(slides))
271
html_text = html_text.replace('\n', '<br>')
272
if self._text_fits_on_slide(html_text):
273
# The first two optional slides fit (as a whole) on one slide. Replace the first occurrence
275
text = text.replace('\n[---]', '', 1)
277
# The first optional slide fits, which means we have to render the first optional slide.
278
text_contains_split = '[---]' in text
279
if text_contains_split:
281
text_to_render, text = text.split('\n[---]\n', 1)
283
text_to_render = text.split('\n[---]\n')[0]
285
text_to_render, raw_tags, html_tags = get_start_tags(text_to_render)
287
text = raw_tags + text
289
text_to_render = text
291
lines = text_to_render.strip('\n').split('\n')
292
slides = self._paginate_slide(lines, line_end)
293
if len(slides) > 1 and text:
294
# Add all slides apart from the last one the list.
295
pages.extend(slides[:-1])
296
if text_contains_split:
297
text = slides[-1] + '\n[---]\n' + text
299
text = slides[-1] + '\n' + text
300
text = text.replace('<br>', '\n')
303
if '[---]' not in text:
304
lines = text.strip('\n').split('\n')
305
pages.extend(self._paginate_slide(lines, line_end))
309
# Clean up line endings.
310
pages = self._paginate_slide(text.split('\n'), line_end)
312
pages = self._paginate_slide(text.split('\n'), line_end)
315
while page.endswith('<br>'):
317
new_pages.append(page)
320
def _calculate_default(self):
322
Calculate the default dimensions of the screen.
324
screen_size = self.screens.current['size']
325
self.width = screen_size.width()
326
self.height = screen_size.height()
327
self.screen_ratio = self.height / self.width
328
self.log_debug('_calculate default {size}, {ratio:f}'.format(size=screen_size, ratio=self.screen_ratio))
329
# 90% is start of footer
330
self.footer_start = int(self.height * 0.90)
332
def get_main_rectangle(self, theme_data):
334
Calculates the placement and size of the main rectangle.
336
:param theme_data: The theme information
338
if not theme_data.font_main_override:
339
return QtCore.QRect(10, 0, self.width - 20, self.footer_start)
341
return QtCore.QRect(theme_data.font_main_x, theme_data.font_main_y,
342
theme_data.font_main_width - 1, theme_data.font_main_height - 1)
344
def get_footer_rectangle(self, theme_data):
346
Calculates the placement and size of the footer rectangle.
348
:param theme_data: The theme data.
350
if not theme_data.font_footer_override:
351
return QtCore.QRect(10, self.footer_start, self.width - 20, self.height - self.footer_start)
353
return QtCore.QRect(theme_data.font_footer_x,
354
theme_data.font_footer_y, theme_data.font_footer_width - 1,
355
theme_data.font_footer_height - 1)
357
def _set_text_rectangle(self, theme_data, rect_main, rect_footer):
359
Sets the rectangle within which text should be rendered.
361
:param theme_data: The theme data.
362
:param rect_main: The main text block.
363
:param rect_footer: The footer text block.
365
self.log_debug('_set_text_rectangle {main} , {footer}'.format(main=rect_main, footer=rect_footer))
366
self._rect = rect_main
367
self._rect_footer = rect_footer
368
self.page_width = self._rect.width()
369
self.page_height = self._rect.height()
370
if theme_data.font_main_shadow:
371
self.page_width -= int(theme_data.font_main_shadow_size)
372
self.page_height -= int(theme_data.font_main_shadow_size)
373
# For the life of my I don't know why we have to completely kill the QWebView in order for the display to work
374
# properly, but we do. See bug #1041366 for an example of what happens if we take this out.
376
self.web = QtWebKitWidgets.QWebView()
377
self.web.setVisible(False)
378
self.web.resize(self.page_width, self.page_height)
379
self.web_frame = self.web.page().mainFrame()
380
# Adjust width and height to account for shadow. outline done in css.
381
html = Template("""<!DOCTYPE html><html><head><script>
382
function show_text(newtext) {
383
var main = document.getElementById('main');
384
main.innerHTML = newtext;
385
// We need to be sure that the page is loaded, that is why we
386
// return the element's height (even though we do not use the
388
return main.offsetHeight;
392
*{margin: 0; padding: 0; border: 0;}
393
#main {position: absolute; top: 0px; ${format_css} ${outline_css}} ${chords_css}
395
<body><div id="main"></div></body></html>""")
396
self.web.setHtml(html.substitute(format_css=build_lyrics_format_css(theme_data,
399
outline_css=build_lyrics_outline_css(theme_data),
400
chords_css=build_chords_css()))
401
self.empty_height = self.web_frame.contentsSize().height()
403
def _paginate_slide(self, lines, line_end):
405
Figure out how much text can appear on a slide, using the current theme settings.
407
**Note:** The smallest possible "unit" of text for a slide is one line. If the line is too long it will be cut
410
:param lines: The text to be fitted on the slide split into lines.
411
:param line_end: The text added after each line. Either ``' '`` or ``'<br>``.
417
html_lines = list(map(expand_tags, lines))
418
# Text too long so go to next page.
419
if not self._text_fits_on_slide(separator.join(html_lines)):
420
html_text, previous_raw = self._binary_chop(
421
formatted, previous_html, previous_raw, html_lines, lines, separator, '')
423
previous_raw = separator.join(lines)
424
formatted.append(previous_raw)
427
def _paginate_slide_words(self, lines, line_end):
429
Figure out how much text can appear on a slide, using the current theme settings.
431
**Note:** The smallest possible "unit" of text for a slide is one word. If one line is too long it will be
432
processed word by word. This is sometimes need for **bible** verses.
434
:param lines: The text to be fitted on the slide split into lines.
435
:param line_end: The text added after each line. Either ``' '`` or ``'<br>``. This is needed for **bibles**.
442
html_line = expand_tags(line)
443
# Text too long so go to next page.
444
if not self._text_fits_on_slide(previous_html + html_line):
445
# Check if there was a verse before the current one and append it, when it fits on the page.
447
if self._text_fits_on_slide(previous_html):
448
formatted.append(previous_raw)
451
# Now check if the current verse will fit, if it does not we have to start to process the verse
453
if self._text_fits_on_slide(html_line):
454
previous_html = html_line + line_end
455
previous_raw = line + line_end
457
# Figure out how many words of the line will fit on screen as the line will not fit as a whole.
458
raw_words = words_split(line)
459
html_words = list(map(expand_tags, raw_words))
460
previous_html, previous_raw = \
461
self._binary_chop(formatted, previous_html, previous_raw, html_words, raw_words, ' ', line_end)
463
previous_html += html_line + line_end
464
previous_raw += line + line_end
465
formatted.append(previous_raw)
468
def _binary_chop(self, formatted, previous_html, previous_raw, html_list, raw_list, separator, line_end):
470
This implements the binary chop algorithm for faster rendering. This algorithm works line based (line by line)
471
and word based (word by word). It is assumed that this method is **only** called, when the lines/words to be
472
rendered do **not** fit as a whole.
474
:param formatted: The list to append any slides.
475
:param previous_html: The html text which is know to fit on a slide, but is not yet added to the list of
476
slides. (unicode string)
477
:param previous_raw: The raw text (with formatting tags) which is know to fit on a slide, but is not yet added
478
to the list of slides. (unicode string)
479
:param html_list: The elements which do not fit on a slide and needs to be processed using the binary chop.
480
The text contains html.
481
:param raw_list: The elements which do not fit on a slide and needs to be processed using the binary chop.
482
The elements can contain formatting tags.
483
:param separator: The separator for the elements. For lines this is ``'<br>'`` and for words this is ``' '``.
484
:param line_end: The text added after each "element line". Either ``' '`` or ``'<br>``. This is needed for
488
highest_index = len(html_list) - 1
489
index = highest_index // 2
491
if not self._text_fits_on_slide(previous_html + separator.join(html_list[:index + 1]).strip()):
492
# We know that it does not fit, so change/calculate the new index and highest_index accordingly.
493
highest_index = index
494
index = index - (index - smallest_index) // 2
496
smallest_index = index
497
index = index + (highest_index - index) // 2
498
# We found the number of words which will fit.
499
if smallest_index == index or highest_index == index:
500
index = smallest_index
501
text = previous_raw.rstrip('<br>') + separator.join(raw_list[:index + 1])
502
text, raw_tags, html_tags = get_start_tags(text)
503
formatted.append(text)
506
# Stop here as the theme line count was requested.
508
Registry().execute('theme_line_count', index + 1)
512
# Check if the remaining elements fit on the slide.
513
if self._text_fits_on_slide(html_tags + separator.join(html_list[index + 1:]).strip()):
514
previous_html = html_tags + separator.join(html_list[index + 1:]).strip() + line_end
515
previous_raw = raw_tags + separator.join(raw_list[index + 1:]).strip() + line_end
518
# The remaining elements do not fit, thus reset the indexes, create a new list and continue.
519
raw_list = raw_list[index + 1:]
520
raw_list[0] = raw_tags + raw_list[0]
521
html_list = html_list[index + 1:]
522
html_list[0] = html_tags + html_list[0]
524
highest_index = len(html_list) - 1
525
index = highest_index // 2
526
return previous_html, previous_raw
528
def _text_fits_on_slide(self, text):
530
Checks if the given ``text`` fits on a slide. If it does ``True`` is returned, otherwise ``False``.
532
:param text: The text to check. It may contain HTML tags.
534
self.web_frame.evaluateJavaScript('show_text'
535
'("{text}")'.format(text=text.replace('\\', '\\\\').replace('\"', '\\\"')))
536
return self.web_frame.contentsSize().height() <= self.empty_height
539
def words_split(line):
541
Split the slide up by word so can wrap better
543
:param line: Line to be split
545
# this parse we are to be wordy
546
return re.split(r'\s+', line)
549
def get_start_tags(raw_text):
551
Tests the given text for not closed formatting tags and returns a tuple consisting of three unicode strings::
553
('{st}{r}Text text text{/r}{/st}', '{st}{r}', '<strong><span style="-webkit-text-fill-color:red">')
555
The first unicode string is the text, with correct closing tags. The second unicode string are OpenLP's opening
556
formatting tags and the third unicode string the html opening formatting tags.
558
:param raw_text: The text to test. The text must **not** contain html tags, only OpenLP formatting tags
560
{st}{r}Text text text
564
for tag in FormattingTags.get_html_tags():
565
if tag['start tag'] == '{br}':
567
if raw_text.count(tag['start tag']) != raw_text.count(tag['end tag']):
568
raw_tags.append((raw_text.find(tag['start tag']), tag['start tag'], tag['end tag']))
569
html_tags.append((raw_text.find(tag['start tag']), tag['start html']))
570
# Sort the lists, so that the tags which were opened first on the first slide (the text we are checking) will be
571
# opened first on the next slide as well.
572
raw_tags.sort(key=lambda tag: tag[0])
573
html_tags.sort(key=lambda tag: tag[0])
574
# Create a list with closing tags for the raw_text.
578
start_tags.append(tag[1])
579
end_tags.append(tag[2])
581
# Remove the indexes.
582
html_tags = [tag[1] for tag in html_tags]
583
return raw_text + ''.join(end_tags), ''.join(start_tags), ''.join(html_tags)