Merge branch 'fix-chords-customs-splits' into 'master'

Properly detect chords, support >5 optional splits

Closes #278

See merge request openlp/openlp!120
This commit is contained in:
Tim Bentley 2020-01-16 17:26:13 +00:00
commit 74cb7218f9
5 changed files with 101 additions and 25 deletions

View File

@ -45,14 +45,15 @@ from openlp.core.lib.formattingtags import FormattingTags
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ENGLISH_NOTES = '[CDEFGAB]'
GERMAN_NOTES = '[CDEFGAH]'
NEOLATIN_NOTES = '(Do|Re|Mi|Fa|Sol|La|Si)'
CHORD_SUFFIXES = '(b|bb)?(#)?(m|maj7|maj|min7|min|sus)?(1|2|3|4|5|6|7|8|9)?'
SLIM_CHARS = 'fiíIÍjlĺľrtť.,;/ ()|"\'!:\\' SLIM_CHARS = 'fiíIÍjlĺľrtť.,;/ ()|"\'!:\\'
CHORD_LINE_MATCH = re.compile(r'\[(.*?)\]([\u0080-\uFFFF,\w]*)'
r'([\u0080-\uFFFF\w\s\.\,\!\?\;\:\|\"\'\-\_]*)(\Z)?')
CHORD_TEMPLATE = '<span class="chordline">{chord}</span>' CHORD_TEMPLATE = '<span class="chordline">{chord}</span>'
FIRST_CHORD_TEMPLATE = '<span class="chordline firstchordline">{chord}</span>' FIRST_CHORD_TEMPLATE = '<span class="chordline firstchordline">{chord}</span>'
CHORD_LINE_TEMPLATE = '<span class="chord"><span><strong>{chord}</strong></span></span>{tail}{whitespace}{remainder}' CHORD_LINE_TEMPLATE = '<span class="chord"><span><strong>{chord}</strong></span></span>{tail}{whitespace}{remainder}'
WHITESPACE_TEMPLATE = '<span class="ws">{whitespaces}</span>' WHITESPACE_TEMPLATE = '<span class="ws">{whitespaces}</span>'
VERSE = 'The Lord said to {r}Noah{/r}: \n' \ VERSE = 'The Lord said to {r}Noah{/r}: \n' \
'There\'s gonna be a {su}floody{/su}, {sb}floody{/sb}\n' \ 'There\'s gonna be a {su}floody{/su}, {sb}floody{/sb}\n' \
'The Lord said to {g}Noah{/g}:\n' \ 'The Lord said to {g}Noah{/g}:\n' \
@ -66,6 +67,72 @@ AUTHOR = 'John Doe'
FOOTER_COPYRIGHT = 'Public Domain' FOOTER_COPYRIGHT = 'Public Domain'
CCLI_NO = '123456' CCLI_NO = '123456'
# Just so we can cache the regular expression objects
_chord_cache = {}
_line_cache = {}
def _construct_chord_regex(notes):
"""
Create the regex for a particular set of notes
:param notes: The regular expression for a set of valid notes
:return: An expanded regular expression for valid chords
"""
chord = notes + CHORD_SUFFIXES
return '(' + chord + '(/' + chord + ')?)'
def _construct_chord_match(notes):
"""
Construct chord matching regular expression object
:param notes: The regular expression for a set of valid notes
:return: A compiled regular expression object
"""
return re.compile(r'\[' + _construct_chord_regex(notes) + r'\]')
def _construct_chord_line_match(notes):
"""
Construct a chord line matching regular expression object
:param notes: The regular expression for a set of valid notes
:return: A compiled regular expression object
"""
return re.compile(r'\[' + _construct_chord_regex(notes) + r'\]([\u0080-\uFFFF,\w]*)'
r'([\u0080-\uFFFF\w\s\.\,\!\?\;\:\|\"\'\-\_]*)(\Z)?')
def _get_chord_match():
"""
Get the right chord_match object based on the current chord notation
"""
notation = Registry().get('settings').value('songs/chord notation')
if notation not in _chord_cache.keys():
if notation == 'german':
_chord_cache[notation] = _construct_chord_match(GERMAN_NOTES)
elif notation == 'neo-latin':
_chord_cache[notation] = _construct_chord_match(NEOLATIN_NOTES)
else:
_chord_cache[notation] = _construct_chord_match(ENGLISH_NOTES)
return _chord_cache[notation]
def _get_line_match():
"""
Get the right chold line match based on the current chord notation
"""
notation = Registry().get('settings').value('songs/chord notation')
if notation not in _line_cache.keys():
if notation == 'german':
_line_cache[notation] = _construct_chord_line_match(GERMAN_NOTES)
elif notation == 'neo-latin':
_line_cache[notation] = _construct_chord_line_match(NEOLATIN_NOTES)
else:
_line_cache[notation] = _construct_chord_line_match(ENGLISH_NOTES)
return _line_cache[notation]
def remove_chords(text): def remove_chords(text):
""" """
@ -73,7 +140,7 @@ def remove_chords(text):
:param text: Text to be cleaned :param text: Text to be cleaned
""" """
return re.sub(r'\[.+?\]', r'', text) return _get_chord_match().sub(r'', text)
def remove_tags(text, can_remove_chords=False): def remove_tags(text, can_remove_chords=False):
@ -120,11 +187,11 @@ def render_chords_in_line(match):
# The actual chord, would be "G" in match "[G]sweet the " # The actual chord, would be "G" in match "[G]sweet the "
chord = match.group(1) chord = match.group(1)
# The tailing word of the chord, would be "sweet" in match "[G]sweet the " # The tailing word of the chord, would be "sweet" in match "[G]sweet the "
tail = match.group(2) tail = match.group(11)
# The remainder of the line, until line end or next chord. Would be " the " in match "[G]sweet the " # The remainder of the line, until line end or next chord. Would be " the " in match "[G]sweet the "
remainder = match.group(3) remainder = match.group(12)
# Line end if found, else None # Line end if found, else None
end = match.group(4) end = match.group(13)
# Based on char width calculate width of chord # Based on char width calculate width of chord
for chord_char in chord: for chord_char in chord:
if chord_char not in SLIM_CHARS: if chord_char not in SLIM_CHARS:
@ -186,6 +253,7 @@ def render_chords(text):
:returns str: The text containing the rendered chords :returns str: The text containing the rendered chords
""" """
text_lines = text.split('{br}') text_lines = text.split('{br}')
chord_line_match = _get_line_match()
rendered_lines = [] rendered_lines = []
chords_on_prev_line = False chords_on_prev_line = False
for line in text_lines: for line in text_lines:
@ -197,7 +265,7 @@ def render_chords(text):
chord_template = FIRST_CHORD_TEMPLATE chord_template = FIRST_CHORD_TEMPLATE
chords_on_prev_line = True chords_on_prev_line = True
# Matches a chord, a tail, a remainder and a line end. See expand_and_align_chords_in_line() for more info. # Matches a chord, a tail, a remainder and a line end. See expand_and_align_chords_in_line() for more info.
new_line = chord_template.format(chord=CHORD_LINE_MATCH.sub(render_chords_in_line, line)) new_line = chord_template.format(chord=chord_line_match.sub(render_chords_in_line, line))
rendered_lines.append(new_line) rendered_lines.append(new_line)
else: else:
chords_on_prev_line = False chords_on_prev_line = False
@ -533,7 +601,7 @@ class ThemePreviewRenderer(LogMixin, DisplayWindow):
if item and item.is_capable(ItemCapabilities.NoLineBreaks): if item and item.is_capable(ItemCapabilities.NoLineBreaks):
line_end = ' ' line_end = ' '
# Bibles # Bibles
if item and item.is_capable(ItemCapabilities.CanWordSplit): if item and item.name == 'bibles':
pages = self._paginate_slide_words(text.split('\n'), line_end) pages = self._paginate_slide_words(text.split('\n'), line_end)
# Songs and Custom # Songs and Custom
elif item is None or (item and item.is_capable(ItemCapabilities.CanSoftBreak)): elif item is None or (item and item.is_capable(ItemCapabilities.CanSoftBreak)):
@ -549,9 +617,9 @@ class ThemePreviewRenderer(LogMixin, DisplayWindow):
text = text.replace(' [---]', '[---]') text = text.replace(' [---]', '[---]')
while '[---] ' in text: while '[---] ' in text:
text = text.replace('[---] ', '[---]') text = text.replace('[---] ', '[---]')
count = 0 # Grab the number of occurrences of "[---]" in the text to use as a max number of loops
# only loop 5 times as there will never be more than 5 incorrect logical splits on a single slide. count = text.count('[---]')
while True and count < 5: for _ in range(count):
slides = text.split('\n[---]\n', 2) slides = text.split('\n[---]\n', 2)
# If there are (at least) two occurrences of [---] we use the first two slides (and neglect the last # If there are (at least) two occurrences of [---] we use the first two slides (and neglect the last
# for now). # for now).

View File

@ -75,6 +75,7 @@ class TestController(TestCase):
self.mocked_renderer.format_slide = self.mocked_slide_formater self.mocked_renderer.format_slide = self.mocked_slide_formater
Registry().register('live_controller', self.mocked_live_controller) Registry().register('live_controller', self.mocked_live_controller)
Registry().register('renderer', self.mocked_renderer) Registry().register('renderer', self.mocked_renderer)
Registry().register('settings', MagicMock(**{'value.return_value': 'english'}))
def test_controller_text_empty(self): def test_controller_text_empty(self):
""" """

View File

@ -52,11 +52,12 @@ def test_remove_tags(mocked_get_tags):
@patch('openlp.core.display.render.FormattingTags.get_html_tags') @patch('openlp.core.display.render.FormattingTags.get_html_tags')
def test_render_tags(mocked_get_tags): def test_render_tags(mocked_get_tags, settings):
""" """
Test the render_tags() method. Test the render_tags() method.
""" """
# GIVEN: Mocked get_html_tags() method. # GIVEN: Mocked get_html_tags() method.
settings.setValue('songs/chord notation', 'english')
mocked_get_tags.return_value = [ mocked_get_tags.return_value = [
{ {
'desc': 'Black', 'desc': 'Black',
@ -91,12 +92,13 @@ def test_render_tags(mocked_get_tags):
assert result_string == expected_string, 'The strings should be identical.' assert result_string == expected_string, 'The strings should be identical.'
def test_render_chords(): def test_render_chords(settings):
""" """
Test that the rendering of chords works as expected. Test that the rendering of chords works as expected.
""" """
# GIVEN: A lyrics-line with chords # GIVEN: A lyrics-line with chords
text_with_chords = 'H[C]alleluya.[F] [G]' settings.setValue('songs/chord notation', 'english')
text_with_chords = 'H[C]alleluya.[F] [G/B]'
# WHEN: Expanding the chords # WHEN: Expanding the chords
text_with_rendered_chords = render_chords(text_with_chords) text_with_rendered_chords = render_chords(text_with_chords)
@ -104,15 +106,16 @@ def test_render_chords():
# THEN: We should get html that looks like below # THEN: We should get html that looks like below
expected_html = '<span class="chordline firstchordline">H<span class="chord"><span><strong>C</strong></span>' \ expected_html = '<span class="chordline firstchordline">H<span class="chord"><span><strong>C</strong></span>' \
'</span>alleluya.<span class="chord"><span><strong>F</strong></span></span><span class="ws">' \ '</span>alleluya.<span class="chord"><span><strong>F</strong></span></span><span class="ws">' \
'&nbsp;&nbsp;</span> <span class="chord"><span><strong>G</strong></span></span></span>' '&nbsp;&nbsp;</span> <span class="chord"><span><strong>G/B</strong></span></span></span>'
assert text_with_rendered_chords == expected_html, 'The rendered chords should look as expected' assert text_with_rendered_chords == expected_html, 'The rendered chords should look as expected'
def test_render_chords_with_special_chars(): def test_render_chords_with_special_chars(settings):
""" """
Test that the rendering of chords works as expected when special chars are involved. Test that the rendering of chords works as expected when special chars are involved.
""" """
# GIVEN: A lyrics-line with chords # GIVEN: A lyrics-line with chords
settings.setValue('songs/chord notation', 'english')
text_with_chords = "I[D]'M NOT MOVED BY WHAT I SEE HALLE[F]LUJA[C]H" text_with_chords = "I[D]'M NOT MOVED BY WHAT I SEE HALLE[F]LUJA[C]H"
# WHEN: Expanding the chords # WHEN: Expanding the chords
@ -155,11 +158,12 @@ def test_compare_chord_lyric_long_chord():
assert ret == 4, 'The returned value should 4 because the chord is longer than the lyric' assert ret == 4, 'The returned value should 4 because the chord is longer than the lyric'
def test_render_chords_for_printing(): def test_render_chords_for_printing(settings):
""" """
Test that the rendering of chords for printing works as expected. Test that the rendering of chords for printing works as expected.
""" """
# GIVEN: A lyrics-line with chords # GIVEN: A lyrics-line with chords
settings.setValue('songs/chord notation', 'english')
text_with_chords = '{st}[D]Amazing {r}gr[D7]ace{/r} how [G]sweet the [D]sound [F]{/st}' text_with_chords = '{st}[D]Amazing {r}gr[D7]ace{/r} how [G]sweet the [D]sound [F]{/st}'
FormattingTags.load_tags() FormattingTags.load_tags()

View File

@ -75,11 +75,12 @@ class TestServiceItem(TestCase, TestMixin):
Set up the Registry Set up the Registry
""" """
self.build_settings() self.build_settings()
Settings().extend_default_settings(__default_settings__) self.setting.extend_default_settings(__default_settings__)
self.setting.setValue('songs/chord notation', 'english')
Registry.create() Registry.create()
Registry().register('settings', self.setting)
# Mock the renderer and its format_slide method # Mock the renderer and its format_slide method
mocked_renderer = MagicMock() mocked_renderer = MagicMock()
Registry().register('settings', Settings())
def side_effect_return_arg(arg1, arg2): def side_effect_return_arg(arg1, arg2):
return [arg1] return [arg1]

View File

@ -23,7 +23,7 @@ Package to test the openlp.plugins.planningcenter.lib.songimport package.
""" """
import datetime import datetime
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import MagicMock, patch
from openlp.core.common.registry import Registry from openlp.core.common.registry import Registry
from openlp.plugins.planningcenter.lib.songimport import PlanningCenterSongImport from openlp.plugins.planningcenter.lib.songimport import PlanningCenterSongImport
@ -38,16 +38,18 @@ class TestSongImport(TestCase, TestMixin):
""" """
Create the class Create the class
""" """
self.registry = Registry() mocked_settings = MagicMock()
mocked_settings.value.return_value = 'english'
Registry.create() Registry.create()
with patch('openlp.core.common.registry.Registry.get'): self.registry = Registry()
self.song_import = PlanningCenterSongImport() self.registry.register('settings', mocked_settings)
self.registry.register('songs', MagicMock())
self.song_import = PlanningCenterSongImport()
def tearDown(self): def tearDown(self):
""" """
Delete all the C++ objects at the end so that we don't have a segfault Delete all the C++ objects at the end so that we don't have a segfault
""" """
del self.registry
del self.song_import del self.song_import
def test_add_song_without_lyrics(self): def test_add_song_without_lyrics(self):