From 12a2a90ea60e1b1964a263d15f430bc5a610d4a0 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Fri, 28 Sep 2018 21:33:40 +0200 Subject: [PATCH] Readded the Renderer class for calculating slide sizes. --- openlp/core/display/html/display.js | 7 + openlp/core/display/render.py | 322 ++++++++++++++++++++++++++++ openlp/core/display/screens.py | 8 +- openlp/core/display/window.py | 1 + openlp/core/lib/__init__.py | 97 +++++++++ openlp/core/lib/serviceitem.py | 143 +++--------- openlp/core/ui/mainwindow.py | 2 + 7 files changed, 470 insertions(+), 110 deletions(-) diff --git a/openlp/core/display/html/display.js b/openlp/core/display/html/display.js index 93a324bd6..3751eb97a 100644 --- a/openlp/core/display/html/display.js +++ b/openlp/core/display/html/display.js @@ -291,6 +291,13 @@ var Display = { $(".slides")[0].innerHTML = ""; this._slides = {}; }, + /** + * Checks if the present slide content fits within the slide + */ + doesContentFit: function () { + console.debug("scrollHeight: " + $(".slides")[0].scrollHeight); + return $(".slides")[0].clientHeight >= $(".slides")[0].scrollHeight; + }, /** * Add a slides. If the slide exists but the HTML is different, update the slide. * @param {string} verse - The verse number, e.g. "v1" diff --git a/openlp/core/display/render.py b/openlp/core/display/render.py index 2f47dea89..696ddec47 100644 --- a/openlp/core/display/render.py +++ b/openlp/core/display/render.py @@ -27,7 +27,14 @@ import logging import math import re +from PyQt5 import QtWidgets + from openlp.core.lib.formattingtags import FormattingTags +from openlp.core.common.registry import Registry, RegistryBase +from openlp.core.common.mixins import LogMixin, RegistryProperties +from openlp.core.display.window import DisplayWindow +from openlp.core.display.screens import ScreenList +from openlp.core.lib import ItemCapabilities log = logging.getLogger(__name__) @@ -354,3 +361,318 @@ def render_tags(text, can_render_chords=False, is_printing=False): text = text.replace(tag['start tag'], tag['start html']) text = text.replace(tag['end tag'], tag['end html']) return text + + +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) + + +def get_start_tags(raw_text): + """ + Tests the given text for not closed formatting tags and returns a tuple consisting of three unicode strings:: + + ('{st}{r}Text text text{/r}{/st}', '{st}{r}', '') + + The first unicode string is the text, with correct closing tags. The second unicode string are OpenLP's opening + formatting tags and the third unicode string the html opening formatting tags. + + :param raw_text: The text to test. The text must **not** contain html tags, only OpenLP formatting tags + are allowed:: + {st}{r}Text text text + """ + raw_tags = [] + html_tags = [] + for tag in FormattingTags.get_html_tags(): + if tag['start tag'] == '{br}': + continue + if raw_text.count(tag['start tag']) != raw_text.count(tag['end tag']): + raw_tags.append((raw_text.find(tag['start tag']), tag['start tag'], tag['end tag'])) + html_tags.append((raw_text.find(tag['start tag']), tag['start html'])) + # Sort the lists, so that the tags which were opened first on the first slide (the text we are checking) will be + # opened first on the next slide as well. + raw_tags.sort(key=lambda tag: tag[0]) + html_tags.sort(key=lambda tag: tag[0]) + # Create a list with closing tags for the raw_text. + end_tags = [] + start_tags = [] + for tag in raw_tags: + start_tags.append(tag[1]) + end_tags.append(tag[2]) + end_tags.reverse() + # Remove the indexes. + html_tags = [tag[1] for tag in html_tags] + return raw_text + ''.join(end_tags), ''.join(start_tags), ''.join(html_tags) + + +class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): + """ + A virtual display used for rendering thumbnails and other offscreen tasks + """ + def __init__(self, *args, **kwargs): + """ + Constructor + """ + super().__init__(*args, **kwargs) + self.force_page = False + for screen in ScreenList(): + if screen.is_display: + self.setGeometry(screen.geometry.x(), screen.geometry.y(), + screen.geometry.width(), screen.geometry.height()) + break + # If the display is not show'ed and hidden like this webegine will not render + self.show() + self.hide() + self.theme_height = 0 + + def calculate_line_count(self): + """ + Calculate the number of lines that fits on one slide + """ + return self.run_javascript('Display.calculateLineCount();', is_sync=True) + + def clear_slides(self): + """ + Clear slides + """ + return self.run_javascript('Display.clearSlides();') + + def format_slide(self, text, item): + """ + Calculate how much text can fit on a slide. + + :param text: The words to go on the slides. + :param item: The :class:`~openlp.core.lib.serviceitem.ServiceItem` item object. + + """ + while not self._is_initialised: + QtWidgets.QApplication.instance().processEvents() + self.log_debug('format slide') + theme_name = item.theme if item.theme else Registry().get('theme_manager').global_theme + theme_data = Registry().get('theme_manager').get_theme_data(theme_name) + self.theme_height = theme_data.font_main_height + + # Set theme for preview + self.set_theme(theme_data) + # Add line endings after each line of text used for bibles. + line_end = '
' + if item.is_capable(ItemCapabilities.NoLineBreaks): + line_end = ' ' + # Bibles + if item.is_capable(ItemCapabilities.CanWordSplit): + pages = self._paginate_slide_words(text.split('\n'), line_end) + # Songs and Custom + elif item.is_capable(ItemCapabilities.CanSoftBreak): + pages = [] + if '[---]' in text: + # Remove Overflow split if at start of the text + if text.startswith('[---]'): + text = text[5:] + # Remove two or more option slide breaks next to each other (causing infinite loop). + while '\n[---]\n[---]\n' in text: + text = text.replace('\n[---]\n[---]\n', '\n[---]\n') + while ' [---]' in text: + text = text.replace(' [---]', '[---]') + while '[---] ' in text: + text = text.replace('[---] ', '[---]') + count = 0 + # only loop 5 times as there will never be more than 5 incorrect logical splits on a single slide. + while True and count < 5: + slides = text.split('\n[---]\n', 2) + # If there are (at least) two occurrences of [---] we use the first two slides (and neglect the last + # for now). + if len(slides) == 3: + html_text = expand_tags('\n'.join(slides[:2])) + # We check both slides to determine if the optional split is needed (there is only one optional + # split). + else: + html_text = expand_tags('\n'.join(slides)) + html_text = html_text.replace('\n', '
') + if self._text_fits_on_slide(html_text): + # The first two optional slides fit (as a whole) on one slide. Replace the first occurrence + # of [---]. + text = text.replace('\n[---]', '', 1) + else: + # The first optional slide fits, which means we have to render the first optional slide. + text_contains_split = '[---]' in text + if text_contains_split: + try: + text_to_render, text = text.split('\n[---]\n', 1) + except ValueError: + text_to_render = text.split('\n[---]\n')[0] + text = '' + text_to_render, raw_tags, html_tags = get_start_tags(text_to_render) + if text: + text = raw_tags + text + else: + text_to_render = text + text = '' + lines = text_to_render.strip('\n').split('\n') + slides = self._paginate_slide(lines, line_end) + if len(slides) > 1 and text: + # Add all slides apart from the last one the list. + pages.extend(slides[:-1]) + if text_contains_split: + text = slides[-1] + '\n[---]\n' + text + else: + text = slides[-1] + '\n' + text + text = text.replace('
', '\n') + else: + pages.extend(slides) + if '[---]' not in text: + lines = text.strip('\n').split('\n') + pages.extend(self._paginate_slide(lines, line_end)) + break + count += 1 + else: + # Clean up line endings. + pages = self._paginate_slide(text.split('\n'), line_end) + else: + pages = self._paginate_slide(text.split('\n'), line_end) + new_pages = [] + for page in pages: + while page.endswith('
'): + page = page[:-4] + new_pages.append(page) + return new_pages + + 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 ``'
``. + """ + formatted = [] + previous_html = '' + previous_raw = '' + separator = '
' + html_lines = list(map(render_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 ``'
``. 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 ``'
'`` and for words this is ``' '``. + :param line_end: The text added after each "element line". Either ``' '`` or ``'
``. 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('
') + 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.clear_slides() + self.run_javascript('Display.addTextSlide("v1", "{text}");'.format(text=text), is_sync=True) + does_text_fits = self.run_javascript('Display.doesContentFit();', is_sync=True) + return does_text_fits diff --git a/openlp/core/display/screens.py b/openlp/core/display/screens.py index eb60931d9..4cbfcbf25 100644 --- a/openlp/core/display/screens.py +++ b/openlp/core/display/screens.py @@ -81,9 +81,13 @@ class Screen(object): :return: A Screen object with the values from screen_dict :rtype: openlp.core.display.screens.Screen """ - screen_dict['geometry'] = QtCore.QRect(**screen_dict['geometry']) + screen_dict['geometry'] = QtCore.QRect(screen_dict['geometry']['x'], screen_dict['geometry']['y'], + screen_dict['geometry']['width'], screen_dict['geometry']['height']) if 'display_geometry' in screen_dict: - screen_dict['display_geometry'] = QtCore.QRect(**screen_dict['display_geometry']) + screen_dict['display_geometry'] = QtCore.QRect(screen_dict['display_geometry']['x'], + screen_dict['display_geometry']['y'], + screen_dict['display_geometry']['width'], + screen_dict['display_geometry']['height']) return cls(**screen_dict) def to_dict(self): diff --git a/openlp/core/display/window.py b/openlp/core/display/window.py index 5232463de..caeed7fd4 100644 --- a/openlp/core/display/window.py +++ b/openlp/core/display/window.py @@ -154,6 +154,7 @@ class DisplayWindow(QtWidgets.QWidget): Add stuff after page initialisation """ self.run_javascript('Display.init();') + self._is_initialised = True def run_javascript(self, script, is_sync=False): """ diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index ee841228c..858c60b25 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -80,6 +80,103 @@ class ServiceItemAction(object): Next = 3 +class ItemCapabilities(object): + """ + Provides an enumeration of a service item's capabilities + + ``CanPreview`` + The capability to allow the ServiceManager to add to the preview tab when making the previous item live. + + ``CanEdit`` + The capability to allow the ServiceManager to allow the item to be edited + + ``CanMaintain`` + The capability to allow the ServiceManager to allow the item to be reordered. + + ``RequiresMedia`` + Determines is the service_item needs a Media Player + + ``CanLoop`` + The capability to allow the SlideController to allow the loop processing. + + ``CanAppend`` + The capability to allow the ServiceManager to add leaves to the + item + + ``NoLineBreaks`` + The capability to remove lines breaks in the renderer + + ``OnLoadUpdate`` + The capability to update MediaManager when a service Item is loaded. + + ``AddIfNewItem`` + Not Used + + ``ProvidesOwnDisplay`` + The capability to tell the SlideController the service Item has a different display. + + ``HasDetailedTitleDisplay`` + Being Removed and decommissioned. + + ``HasVariableStartTime`` + The capability to tell the ServiceManager that a change to start time is possible. + + ``CanSoftBreak`` + The capability to tell the renderer that Soft Break is allowed + + ``CanWordSplit`` + The capability to tell the renderer that it can split words is + allowed + + ``HasBackgroundAudio`` + That a audio file is present with the text. + + ``CanAutoStartForLive`` + The capability to ignore the do not play if display blank flag. + + ``CanEditTitle`` + The capability to edit the title of the item + + ``IsOptical`` + Determines is the service_item is based on an optical device + + ``HasDisplayTitle`` + The item contains 'displaytitle' on every frame which should be + preferred over 'title' when displaying the item + + ``HasNotes`` + The item contains 'notes' + + ``HasThumbnails`` + The item has related thumbnails available + + ``HasMetaData`` + The item has Meta Data about item + """ + CanPreview = 1 + CanEdit = 2 + CanMaintain = 3 + RequiresMedia = 4 + CanLoop = 5 + CanAppend = 6 + NoLineBreaks = 7 + OnLoadUpdate = 8 + AddIfNewItem = 9 + ProvidesOwnDisplay = 10 + # HasDetailedTitleDisplay = 11 + HasVariableStartTime = 12 + CanSoftBreak = 13 + CanWordSplit = 14 + HasBackgroundAudio = 15 + CanAutoStartForLive = 16 + CanEditTitle = 17 + IsOptical = 18 + HasDisplayTitle = 19 + HasNotes = 20 + HasThumbnails = 21 + HasMetaData = 22 + + def get_text_file_string(text_file_path): """ Open a file and return its content as a string. If the supplied file path is not a file then the function diff --git a/openlp/core/lib/serviceitem.py b/openlp/core/lib/serviceitem.py index 5baafcd84..de27e48b9 100644 --- a/openlp/core/lib/serviceitem.py +++ b/openlp/core/lib/serviceitem.py @@ -40,7 +40,7 @@ from openlp.core.common.mixins import RegistryProperties from openlp.core.common.path import Path from openlp.core.common.settings import Settings from openlp.core.display.render import remove_tags, render_tags -from openlp.core.lib import ImageSource, build_icon +from openlp.core.lib import ImageSource, ItemCapabilities, build_icon log = logging.getLogger(__name__) @@ -54,103 +54,6 @@ class ServiceItemType(object): Command = 3 -class ItemCapabilities(object): - """ - Provides an enumeration of a service item's capabilities - - ``CanPreview`` - The capability to allow the ServiceManager to add to the preview tab when making the previous item live. - - ``CanEdit`` - The capability to allow the ServiceManager to allow the item to be edited - - ``CanMaintain`` - The capability to allow the ServiceManager to allow the item to be reordered. - - ``RequiresMedia`` - Determines is the service_item needs a Media Player - - ``CanLoop`` - The capability to allow the SlideController to allow the loop processing. - - ``CanAppend`` - The capability to allow the ServiceManager to add leaves to the - item - - ``NoLineBreaks`` - The capability to remove lines breaks in the renderer - - ``OnLoadUpdate`` - The capability to update MediaManager when a service Item is loaded. - - ``AddIfNewItem`` - Not Used - - ``ProvidesOwnDisplay`` - The capability to tell the SlideController the service Item has a different display. - - ``HasDetailedTitleDisplay`` - Being Removed and decommissioned. - - ``HasVariableStartTime`` - The capability to tell the ServiceManager that a change to start time is possible. - - ``CanSoftBreak`` - The capability to tell the renderer that Soft Break is allowed - - ``CanWordSplit`` - The capability to tell the renderer that it can split words is - allowed - - ``HasBackgroundAudio`` - That a audio file is present with the text. - - ``CanAutoStartForLive`` - The capability to ignore the do not play if display blank flag. - - ``CanEditTitle`` - The capability to edit the title of the item - - ``IsOptical`` - Determines is the service_item is based on an optical device - - ``HasDisplayTitle`` - The item contains 'displaytitle' on every frame which should be - preferred over 'title' when displaying the item - - ``HasNotes`` - The item contains 'notes' - - ``HasThumbnails`` - The item has related thumbnails available - - ``HasMetaData`` - The item has Meta Data about item - """ - CanPreview = 1 - CanEdit = 2 - CanMaintain = 3 - RequiresMedia = 4 - CanLoop = 5 - CanAppend = 6 - NoLineBreaks = 7 - OnLoadUpdate = 8 - AddIfNewItem = 9 - ProvidesOwnDisplay = 10 - # HasDetailedTitleDisplay = 11 - HasVariableStartTime = 12 - CanSoftBreak = 13 - CanWordSplit = 14 - HasBackgroundAudio = 15 - CanAutoStartForLive = 16 - CanEditTitle = 17 - IsOptical = 18 - HasDisplayTitle = 19 - HasNotes = 20 - HasThumbnails = 21 - HasMetaData = 22 - - class ServiceItem(RegistryProperties): """ The service item is a base class for the plugins to use to interact with @@ -249,17 +152,45 @@ class ServiceItem(RegistryProperties): else: self.icon = UiIcons().clone + def _create_slides(self): + """ + Create frames for rendering and display + """ + self._rendered_slides = [] + self._display_slides = [] + + # Save rendered pages to this dict. In the case that a slide is used twice we can use the pages saved to + # the dict instead of rendering them again. + previous_pages = {} + #for slide in self._raw_frames: + for raw_slide in self.slides: + verse_tag = raw_slide['verse'] + if verse_tag in previous_pages and previous_pages[verse_tag][0] == raw_slide: + pages = previous_pages[verse_tag][1] + else: + pages = self.renderer.format_slide(raw_slide['text'], self) + previous_pages[verse_tag] = (raw_slide, pages) + for page in pages: + rendered_slide = { + 'title': raw_slide['title'], + 'text': render_tags(page), + 'verse': verse_tag, + } + self._rendered_slides.append(rendered_slide) + display_slide = { + 'title': raw_slide['title'], + 'text': remove_tags(page), + 'verse': verse_tag, + } + self._display_slides.append(display_slide) + @property def rendered_slides(self): """ Render the frames and return them """ if not self._rendered_slides: - self._rendered_slides = [] - for raw_slide in self.slides: - rendered_slide = deepcopy(raw_slide) - rendered_slide['text'] = render_tags(rendered_slide['text']) - self._rendered_slides.append(rendered_slide) + self._create_slides() return self._rendered_slides @property @@ -268,11 +199,7 @@ class ServiceItem(RegistryProperties): Render the frames and return them """ if not self._display_slides: - self._display_slides = [] - for raw_slide in self.slides: - display_slide = deepcopy(raw_slide) - display_slide['text'] = remove_tags(display_slide['text']) - self._display_slides.append(display_slide) + self._create_slides() return self._display_slides # def render(self, provides_own_theme_data=False): diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 6f77ffa9f..266b7f28f 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -43,6 +43,7 @@ from openlp.core.common.registry import Registry from openlp.core.common.settings import Settings from openlp.core.display.screens import ScreenList from openlp.core.lib.imagemanager import ImageManager +from openlp.core.display.render import Renderer from openlp.core.lib.plugin import PluginStatus from openlp.core.lib.pluginmanager import PluginManager from openlp.core.lib.ui import create_action @@ -509,6 +510,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow, LogMixin, RegistryPropert # Set up the path with plugins PluginManager(self) ImageManager() + Renderer() # Set up the interface self.setup_ui(self) # Define the media Dock Manager