diff --git a/src/epub/create_epub.py b/src/epub/create_epub.py
index 7973c45d..58d2d546 100644
--- a/src/epub/create_epub.py
+++ b/src/epub/create_epub.py
@@ -8,7 +8,10 @@
from zipfile import ZipFile
from datetime import datetime
-import src.html.create_songs_html as cash
+from src.html.kindle_html_converter import KindleHtmlConverter
+from src.html import html_converter_utils as converter_utils
+from src.html import song_utils
+
import src.lib.songbook as sb
import src.lib.any_index_generator as aig
import src.lib.img2cover.img2cover as img2cover
@@ -25,7 +28,7 @@ def actual_datetime():
# def name_of_file(song):
# return os.path.splitext(os.path.split(song)[1])[0]
-CSS_FILES = ["song.css", "common.css", "song_common.css", "index.css"]
+CSS_FILES = ["song_kindle.css", "common.css", "song_common.css", "index.css"]
def create_content_opf(songbook, list_of_songs_meta, target_dir, pre_files=[], post_files=[]):
tmp_path =os.path.join(sb.repo_dir(), "src", "epub", "templates", "content.opf")
@@ -220,11 +223,11 @@ def create_toc_xhtml(list_of_songs_meta, target_dir, page_suffix):
et = etree.ElementTree(root)
et.write(out_path, pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True)
- cash.replace_in_file(out_path, out_path, lambda s: s.replace("
","\n\t\t").replace(">",">\n"))
+ converter_utils.replace_in_file(out_path, out_path, lambda s: s.replace("
","\n\t\t").replace(">",">\n"))
return files
def resolveTemplate(songbook, from_file, to_file):
- return cash.replace_in_file(from_file, to_file,
+ return converter_utils.replace_in_file(from_file, to_file,
lambda s: (s.replace(":date:", actual_date())
.replace(":datetime:", actual_datetime())
.replace(":title:", songbook.title())
@@ -290,7 +293,8 @@ def create_full_epub(songbook, target_dir):
create_template_epub(songbook, target_dir)
path_out = os.path.join(target_dir, "epub", "OEBPS")
- cash.create_all_songs_html(los, path_out, list([suffix]))
+ converter = KindleHtmlConverter()
+ song_utils.create_all_songs_html(converter, los, path_out, list([suffix]))
files = []
files.extend(create_toc_xhtml(los, target_dir, page_suffix = suffix))
diff --git a/src/epub/templates/content.opf b/src/epub/templates/content.opf
index 01dca7b3..71a1bb97 100755
--- a/src/epub/templates/content.opf
+++ b/src/epub/templates/content.opf
@@ -23,7 +23,7 @@
-
+
diff --git a/src/epub/templates/song_kindle.css b/src/epub/templates/song_kindle.css
new file mode 100644
index 00000000..7d72a1a1
--- /dev/null
+++ b/src/epub/templates/song_kindle.css
@@ -0,0 +1,162 @@
+@import url('common.css');
+
+body {
+ font-family: sans-serif;
+}
+
+div.song_body {
+ width: max-content;
+ display: table;
+}
+
+/* Per block settings - Chorus, Verses, Instrumental */
+
+div.verse, div.chorus, div.other {
+ display: contents;
+}
+
+div.chorus div.row span.lyric {
+ padding-left: 3ex;
+}
+
+div.block_spacer {
+ display: table-row;
+ empty-cells: show;
+ height: 2.5ex;
+}
+
+/* Per row settings*/
+
+div.row {
+ display: table-row;
+ page-break-inside: avoid;
+ page-break-after: avoid;
+}
+
+div.row:last-of-type {
+ page-break-after: auto;
+}
+
+/* Lyrics */
+
+span.block_id, span.lyric, span.chords, span.bis_inactive, span.bis_active, span.chords_ins {
+ display: table-cell;
+}
+
+span.block_id {
+ padding-top: 1ex;
+ page-break-inside: avoid;
+ page-break-after: avoid;
+}
+/* margin-right: 0.5em;*/
+/*}*/
+
+/* As there is no call-span in CSS we allow the instrumental row to overflow */
+span.chords_ins {
+ max-width: 5em;
+ padding-left: 1em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Chords above the text */
+
+span.lyric span.chunk {
+ display: inline-table;
+ /*border: 1px solid green;*/
+ vertical-align: bottom;
+}
+
+span.lyric span.chord {
+ display: table-row;
+ /*border: 1px solid orange;*/
+ max-height: 0;
+}
+
+span.lyric span.chord span.ch{
+ display: table-cell;
+}
+
+span.lyric span.content {
+ display: table-row;
+ /*border: 1px solid red;*/
+ /*overflow-wrap: normal;*/
+ /*white-space: nowrap;*/
+ /*hyphens: none;*/
+}
+
+span.lyric span.content-i {
+ display: table-cell;
+ /*border: 1px solid red;*/
+ white-space: pre-line;
+ overflow-wrap: normal;
+ /*white-space: nowrap;*/
+ /*empty-cells: show;*/
+ hyphens: none;
+ height: 2.2ex;
+}
+
+span.ws {
+ display: inline-block;
+ width: 0.5ex;
+ opacity: 0;
+}
+
+div.over_false span.lyric span.ch {
+ display: none;
+}
+
+/*!* Generic styling*!*/
+
+h1 {
+ text-align: left;
+}
+
+span.lyric span.ch {
+ font-family: sans-serif;
+ font-size: xx-small;
+ font-weight: bolder;
+}
+
+span.ch {
+ font-family: sans-serif;
+ font-weight: bolder;
+}
+
+span.chords span.ch {
+ padding-right: 0.3em;
+}
+
+span.chords {
+ padding-left: 1em;
+ padding-right: 1em;
+ max-width: 10em;
+ overflow-wrap: break-word;
+
+ /* not sure if needed... commenting for now*/
+ /* overflow-wrap: break-word;*/
+ /* hyphens: none;*/
+ /* white-space: nowrap;*/
+}
+
+div.row > span.bis_inactive {
+ padding-right: 0.4em;
+ empty-cells: show;
+ width: 2em;
+}
+
+div.row > span.bis_active {
+ padding-left: 0.4em;
+ padding-right: 0.3em;
+ border-right: solid;
+ empty-cells: show;
+ text-align: right;
+ width: 2em; /* fixed length makes it more likely on kindle to finish at the same place */
+}
+
+div.comment {
+ margin-top: 1.5em;
+ font-style: italic;
+ white-space: pre-wrap;
+ font-size: 0.7em;
+}
diff --git a/src/html/__init__.py b/src/html/__init__.py
new file mode 100644
index 00000000..80c75f65
--- /dev/null
+++ b/src/html/__init__.py
@@ -0,0 +1 @@
+# Empty file to mark directory as Python package
diff --git a/src/html/create_songs_html.py b/src/html/create_songs_html.py
deleted file mode 100644
index a4176ace..00000000
--- a/src/html/create_songs_html.py
+++ /dev/null
@@ -1,278 +0,0 @@
-# Tworzy piosenki w xhtml
-import os
-import copy
-from lxml import etree
-import src.lib.read_song_xml as rsx
-
-
-def _add_chunk(chunk, parent, position):
- """class chunk -> html span chunk"""
- span_chunk = etree.SubElement(parent, "span", attrib={"class": "chunk"})
- chord = etree.SubElement(span_chunk, "span", attrib={"class": "chord"})
- span_ch = etree.SubElement(chord, "span", attrib={"class": "ch"})
- span_ch.text = chunk.chord
- span_content = etree.SubElement(span_chunk, "span", attrib={"class": "content"})
- # Nested spans help with formatting as table.
- span_content = etree.SubElement(span_content, "span", attrib={"class": "content-i"})
- if position == 0:
- if chunk.content.startswith(' '):
- span_content.text = chunk.content
- else:
- span_content.text = ' ' + chunk.content
- else:
- if chunk.content=='':
- span_content.text = ' '
- else:
- span_content.text = chunk.content
-
-
-def _add_lyric(row, parent):
- """class lyric -> html span lyric"""
- span_lyric = etree.SubElement(parent, "span", attrib={"class": "lyric"})
- for i, chunk in enumerate(row.chunks):
- _add_chunk(chunk, span_lyric, i)
-
-
-def _add_chords(row, parent, class_name):
- """class chords -> html span ch"""
- span_chords = etree.SubElement(parent, "span", attrib={"class": class_name})
- if row.sidechords:
- for chunk in row.sidechords.split(" "):
- span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
- span_ch.text = chunk
- else:
- for chunk in row.chunks:
- if len(chunk.chord) > 0:
- span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
- span_ch.text = chunk.chord
-
-
-def _add_bis(row, div_row):
- if str(type(row.bis)) == "" and row.bis is True:
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = u'\u00a0'
- elif str(type(row.bis)) == "":
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = 'x' + str(row.bis)
- else:
- span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
- span_unbis.text = u'\u00a0'
-
-
-def _add_row(row, parent):
- """class row -> html div row with content"""
- if (row.new_chords == 0) or (row.new_chords is False):
- chords_over = "over_false"
- else:
- chords_over = "over_true"
- div_row = etree.SubElement(parent, "div", attrib={"class": "row " + chords_over})
- div_row.text = u'\u200d' # Not visible spaces -> forces the row to be generated as a single line. We remove it in post-processing.
- _add_chords(row, div_row, "chords")
- _add_lyric(row, div_row)
- if str(type(row.bis)) == "" and row.bis is True:
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = u'\u00a0'
- elif str(type(row.bis)) == "":
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = 'x' + str(row.bis)
- else:
- span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
- span_unbis.text =u'\u00a0'
-
-def _add_instrumental_row(row, parent):
- """class row instrumental-> html div row with content"""
- div_row = etree.SubElement(parent, "div", attrib={"class": "row"})
- if str(type(row.bis)) == "" and row.bis is True:
- _add_chords(row, div_row, "chords_ins")
- _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = u'\u00a0'
- elif str(type(row.bis)) == "":
- _add_chords(row, div_row, "chords_ins")
- _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
- span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
- span_bis.text = 'x' + str(row.bis)
- else:
- _add_chords(row, div_row, "chords_ins")
- _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
- span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
- span_unbis.text = u'\u00a0'
-
-def _add_block(block, parent, block_type, verse_cnt):
- """class verse -> html div verse/chorus/other with content"""
- div_verse = etree.SubElement(parent, "div", attrib={"class": block_type})
-
- for ro in block.rows:
- if ro.instr:
- _add_instrumental_row(ro, div_verse)
- else:
- _add_row(ro, div_verse)
-
-
-def _add_creator(creator, describe, parent):
- """class creator -> html div creator # metadane piosenki"""
- div = etree.SubElement(parent, "div", attrib={"class": "creator"})
- span_label = etree.SubElement(div, "span", attrib={"class": "label"})
- span_label.text = describe
- span_content = etree.SubElement(div, "span", attrib={"class": "content_creator"})
- span_content.text = creator
-
-def _add_blocks(song, parent):
- body_song = parent.find("body")
- """class song -> html div body # blok z metadanymi o piosence i piosenką"""
- h1_title = etree.SubElement(body_song, "h1", attrib={"class": "title", "id": "title"})
- h1_title.text = song.title
- if song.original_title:
- _add_creator(song.original_title, "Tytuł oryginalny: ", body_song)
- if song.alias:
- _add_creator(song.alias, "Tytuł alternatywny: ", body_song)
- if song.text_author:
- _add_creator(song.text_author, "Słowa: ", body_song)
- if song.translator:
- _add_creator(song.translator, "Tłumaczenie: ", body_song)
- if song.composer:
- _add_creator(song.composer, "Muzyka: ", body_song)
- if song.music_source:
- _add_creator(song.music_source, "Melodia oparta na: ", body_song)
- if song.artist:
- _add_creator(song.artist, "Wykonawca: ", body_song)
- if song.album:
- _add_creator(song.album, "Album: ", body_song)
- if song.metre:
- _add_creator(song.metre, "Metrum: ", body_song)
- if song.barre and int(song.barre) > 0:
- _add_creator(song.barre, "Kapodaster: ", body_song)
-
- corpse = etree.SubElement(body_song, "div", attrib={"class": "song_body", "id": "song_body"})
- verse_cnt = 0
- for block in song.blocks:
- if block.block_type.value == 'V':
- b_type = "verse"
- verse_cnt = verse_cnt + 1
- elif block.block_type.value == 'C':
- b_type = "chorus"
- else:
- b_type = "other"
- blockid=""
- if b_type == "verse":
- blockid=str(verse_cnt) + "."
- if b_type == "chorus":
- blockid="Ref:"
-
-
- spacer = etree.SubElement(corpse, "div", attrib={"class": "block_spacer"})
- block_id = etree.SubElement(spacer, "span", attrib={"class": "block_id"})
- block_id.text = blockid
- _add_block(block, corpse, b_type, verse_cnt)
-
- if song.comment:
- div = etree.SubElement(body_song, "div", attrib={"class": "comment"})
- span_content = etree.SubElement(div, "span", attrib={"class": "comment"})
- span_content.text = song.comment
-
-def substitute_from_dict(text, replacements):
- """
- Substitutes placeholders in a string with values from a dictionary.
-
- Args:
- text (str): The input string containing placeholders (e.g., '{{placeholder}}').
- replacements (dict): A dictionary where keys are the placeholders to find
- and values are the strings to substitute them with.
-
- Returns:
- str: The string with all placeholders substituted.
- """
- for old_string, new_string in replacements.items():
- text = text.replace(old_string, new_string)
- return text
-
-
-def interpret(c, substitions):
- # recursively replace in c all attributes and texts that contains keys from substitions with provided values.
- if isinstance(c, etree._Element):
- for attr in c.attrib:
- c.attrib[attr] = substitute_from_dict(c.attrib[attr], substitions)
- for child in c:
- interpret(child, substitions)
- if c.text:
- c.text = substitute_from_dict(c.text, substitions)
- return c
-
-
-def xml2html(src_xml_path, path_out, song_suffix = None, song_prefix = None, song_head = None, substitions={}): # tworzy piosenkę w wersji html
-
- xhtml_namespace = "http://www.w3.org/1999/xhtml"
- epub_namespace = "http://www.idpf.org/2007/ops"
- xhtml = "{%s}" % xhtml_namespace
- nsmap = {None: xhtml_namespace,
- "epub": epub_namespace}
-
- song = rsx.parse_song_xml(src_xml_path)
- root_html = etree.Element(xhtml + "html", nsmap=nsmap)
- root_html.attrib[etree.QName("lang")] = "pl-PL"
- head = etree.SubElement(root_html, "head")
- etree.SubElement(head, "link",
- attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song_common.css", "media": "all"})
- etree.SubElement(head, "link",
- attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song.css", "media": "all"})
- etree.SubElement(head, "meta", attrib={"name": "viewport", "content": "width=device-width, initial-scale=0.8"})
-
- for s in song_head:
- head.append(interpret(copy.deepcopy(s), substitions))
-
- body = etree.SubElement(root_html, "body", attrib={"class": "song", etree.QName("http://www.idpf.org/2007/ops", "type"):"bodymatter"})
- for s in song_prefix:
- body.append(interpret(copy.deepcopy(s), substitions))
-
- _add_blocks(song, root_html)
- title = etree.SubElement(head, "title")
- title.text = song.title
- et = etree.ElementTree(root_html)
- for s in song_suffix:
- body.append(interpret(copy.deepcopy(s), substitions))
-
- et.write(path_out, doctype='', pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True)
-
- replace_in_file(path_out, path_out, lambda s: s.replace(u'\u200d', '').replace(" ", "_").replace(' ', '_'))
-
-def replace_in_file(sourceFile, targetFile, f):
- with open(sourceFile, 'r') as file:
- filedata = file.read()
-
- # Replace the target string
- filedata = f(filedata)
-
- # Write the file out again
- with open(targetFile, 'w') as file:
- file.write(filedata)
-
-def create_list_of_songs(song_set):
- """ Dostaje jako argument listę piosenek lub ścieżkę do katalogu i zwraca listę piosenek """
- if str(type(song_set)) == "":
- for i in range(len(song_set)):
- if song_set[i][-4:] == '.xml':
- song_set[i] = song_set[i][0:-4]
- return song_set
- else:
- songs_list = os.listdir(song_set)
- for i in range(len(songs_list)):
- if songs_list[i][-4:] == '.xml':
- songs_list[i] = songs_list[i][0:-4]
- return songs_list
-
-
-def create_all_songs_html_from_dir(path_in, path_out, song_suffix=[]):
- """Tworzy wszystkie piosenki z listy w formacie html w katalogu path_out"""
-
- return create_all_songs_html( create_list_of_songs(path_in), path_out, song_suffix)
-
-def create_all_songs_html(list_of_songs, path_out, song_suffix=[], song_prefix=[], song_head=[], substitions={}):
- """Tworzy wszystkie piosenki z listy w formacie html w katalogu path_out"""
-
- if not os.path.exists(path_out):
- os.mkdir(path_out)
-
- for song in list_of_songs:
- if not song.is_alias():
- relative_path = os.path.relpath(song.plik(), start=os.path.join(os.path.dirname(__file__), "../../songs"))
- xml2html(song.plik(), os.path.join(path_out, song.base_file_name() + '.xhtml'), song_suffix=song_suffix, song_prefix=song_prefix, song_head=song_head, substitions=substitions | {"{{SRC}}": relative_path, "{{BASE_FILENAME}}": song.base_file_name()})
diff --git a/src/html/css/song.css b/src/html/css/song.css
index 5e253543..6e1e8b92 100644
--- a/src/html/css/song.css
+++ b/src/html/css/song.css
@@ -1 +1,141 @@
-@import url('song_common.css');
\ No newline at end of file
+.lyric {
+ display: block;
+ white-space: normal;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+}
+
+.ch-stack {
+ width: 0px;
+ bottom: 100%;
+ height: 2em;
+ top: -2ex;
+ display: inline-block;
+ vertical-align: bottom;
+ word-break: keep-all;
+ overflow-wrap: normal;
+}
+
+div.lyric span.ch {
+ font-weight: bolder;
+ white-space: nowrap;
+ padding-left: 0px;
+ padding-right: 0.3em;
+ font-size: x-small;
+}
+
+div.lyric span.ch[aria-hidden="true"] {
+ display: none;
+}
+
+.sidechords {
+ float: right;
+ margin-left: 1em;
+}
+
+.sidechords .chord {
+ margin-left: 0.5em;
+}
+
+.chords, .chords_ins {
+ display: inline-flex;
+ font-weight: bold;
+}
+
+.bis {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.bis_inactive {
+ visibility: hidden;
+}
+
+div.block_spacer {
+ margin-top: 4ex;
+}
+
+div.row {
+ page-break-inside: avoid;
+ page-break-after: avoid;
+}
+
+div.row:last-of-type {
+ page-break-after: auto;
+}
+
+/* TODO: Check instrumental row behavior */
+
+div.over_false span.lyric span.ch {
+ display: none;
+}
+
+
+div.comment {
+ margin-top: 1.5em;
+ font-style: italic;
+ white-space: pre-wrap;
+ font-size: smaller;
+}
+
+.chords span.ch, .chords_ins span.ch {
+ padding-right: 0.3em;
+}
+
+
+/** Grid layout for verse, chorus, other */
+
+.verse, .chorus, .other {
+ display: grid;
+ grid-template-columns: max-content max-content max-content;
+}
+
+.row {
+ /*As verse is a grid layout, we use display: contents to allow the grid layout to apply to the children of the row*/
+ display: contents;
+}
+
+.chords {
+ grid-column: 1;
+ align-items: end;
+ padding-right: 2em;
+}
+
+.chords_ins {
+ grid-column: 1 / span 2;
+ align-items: end;
+ padding-right: 2em;
+}
+
+.lyric {
+ grid-column: 2;
+}
+
+.bis, .bis_inactive, .bis_active {
+ grid-column: 3;
+}
+
+/** Intendation of verses */
+
+.verse {
+ padding-left: 1.5em;
+}
+
+.chorus {
+ padding-left: 3em;
+}
+
+.other {
+ padding-left: 1.5em;
+}
+
+/* Bis support */
+
+.bis_active {
+ border-left: solid;
+ margin-left: 1em;
+ padding-left: 0.3em;
+ text-align: right;
+}
\ No newline at end of file
diff --git a/src/html/css/song_common.css b/src/html/css/song_common.css
index c25d2745..45cc353d 100644
--- a/src/html/css/song_common.css
+++ b/src/html/css/song_common.css
@@ -5,166 +5,13 @@ body {
font-family: sans-serif;
}
-div.song_body {
- width: max-content;
- display: table;
-}
-
-/* Per block settings - Chorus, Verses, Instrumental */
-
-div.verse, div.chorus, div.other {
- display: contents;
-}
-
-div.chorus div.row span.lyric {
- padding-left: 3ex;
-}
-
-div.block_spacer {
- display: table-row;
- empty-cells: show;
- height: 2.5ex;
-}
-
-/* Per row settings*/
-
-div.row {
- display: table-row;
- page-break-inside: avoid;
- page-break-after: avoid;
-}
-
-div.row:last-of-type {
- page-break-after: auto;
-}
-
-/* Lyrics */
-
-span.block_id, span.lyric, span.chords, span.bis_inactive, span.bis_active, span.chords_ins {
- display: table-cell;
-}
-
-span.block_id {
- padding-top: 1ex;
- page-break-inside: avoid;
- page-break-after: avoid;
-}
-/* margin-right: 0.5em;*/
-/*}*/
-
-/* As there is no call-span in CSS we allow the instrumental row to overflow */
-span.chords_ins {
- max-width: 5em;
- padding-left: 1em;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Chords above the text */
-
-span.lyric span.chunk {
- display: inline-table;
- /*border: 1px solid green;*/
- vertical-align: bottom;
-}
-
-span.lyric span.chord {
- display: table-row;
- /*border: 1px solid orange;*/
- max-height: 0;
-}
-
-span.lyric span.chord span.ch{
- display: table-cell;
-}
-
-span.lyric span.content {
- display: table-row;
- /*border: 1px solid red;*/
- /*overflow-wrap: normal;*/
- /*white-space: nowrap;*/
- /*hyphens: none;*/
-}
-
-span.lyric span.content-i {
- display: table-cell;
- /*border: 1px solid red;*/
- white-space: pre-line;
- overflow-wrap: normal;
- /*white-space: nowrap;*/
- /*empty-cells: show;*/
- hyphens: none;
- height: 2.2ex;
-}
-
-span.ws {
- display: inline-block;
- width: 0.5ex;
- opacity: 0;
-}
-
-div.over_false span.lyric span.ch {
- display: none;
-}
-
-/*!* Generic styling*!*/
-
h1 {
text-align: left;
}
-span.lyric span.ch {
- font-family: sans-serif;
- font-size: xx-small;
- font-weight: bolder;
-}
-
-span.ch {
- font-family: sans-serif;
- font-weight: bolder;
-}
-
-span.chords span.ch {
- padding-right: 0.3em;
-}
-
-span.chords {
- padding-left: 1em;
- padding-right: 1em;
- max-width: 10em;
- overflow-wrap: break-word;
-
- /* not sure if needed... commenting for now*/
- /* overflow-wrap: break-word;*/
- /* hyphens: none;*/
- /* white-space: nowrap;*/
-}
-
span.content_creator {
margin-top: 0.0em;
font-style: italic;
font-weight: bold;
font-size: small;
-}
-
-div.row > span.bis_inactive {
- padding-right: 0.4em;
- empty-cells: show;
- width: 2em;
-}
-
-div.row > span.bis_active {
- padding-left: 0.4em;
- padding-right: 0.3em;
- border-right: solid;
- empty-cells: show;
- text-align: right;
- width: 2em; /* fixed length makes it more likely on kindle to finish at the same place */
-}
-
-div.comment {
- margin-top: 1.5em;
- font-style: italic;
- white-space: pre-wrap;
- font-size: 0.7em;
-}
+}
\ No newline at end of file
diff --git a/src/html/html_converter_utils.py b/src/html/html_converter_utils.py
new file mode 100644
index 00000000..dc5daceb
--- /dev/null
+++ b/src/html/html_converter_utils.py
@@ -0,0 +1,24 @@
+import copy
+from lxml import etree
+
+def substitute_from_dict(text, replacements):
+ for old_string, new_string in replacements.items():
+ text = text.replace(old_string, new_string)
+ return text
+
+def interpret(c, substitions):
+ if isinstance(c, etree._Element):
+ for attr in c.attrib:
+ c.attrib[attr] = substitute_from_dict(c.attrib[attr], substitions)
+ for child in c:
+ interpret(child, substitions)
+ if c.text:
+ c.text = substitute_from_dict(c.text, substitions)
+ return c
+
+def replace_in_file(sourceFile, targetFile, f):
+ with open(sourceFile, 'r') as file:
+ filedata = file.read()
+ filedata = f(filedata)
+ with open(targetFile, 'w') as file:
+ file.write(filedata)
diff --git a/src/html/htmls_generator.py b/src/html/htmls_generator.py
index 4f336d01..d310d6e6 100644
--- a/src/html/htmls_generator.py
+++ b/src/html/htmls_generator.py
@@ -1,8 +1,8 @@
import os
import shutil
-
-import src.html.create_songs_html as cash
import sys
+from src.html.standard_html_converter import StandardHtmlConverter
+from src.html.song_utils import create_all_songs_html
import src.lib.songbook as sb
from lxml import etree
@@ -34,7 +34,14 @@ def main():
_song_head = load_template('_song_head.xhtml')
_song_suffix = load_template('_song_suffix.xhtml')
- cash.create_all_songs_html(songbook.list_of_songs(), path_songs_html, song_prefix=_song_prefix, song_suffix=_song_suffix, song_head=_song_head)
+ converter = StandardHtmlConverter()
+ create_all_songs_html(converter,
+ songbook.list_of_songs(),
+ path_songs_html,
+ song_prefix=_song_prefix,
+ song_suffix=_song_suffix,
+ song_head=_song_head)
+
path_css = os.path.join(path_songs_html, "CSS")
os.mkdir(path_css)
diff --git a/src/html/kindle_html_converter.py b/src/html/kindle_html_converter.py
new file mode 100644
index 00000000..6a039cb5
--- /dev/null
+++ b/src/html/kindle_html_converter.py
@@ -0,0 +1,197 @@
+from lxml import etree
+import copy
+import src.lib.read_song_xml as rsx
+from .song_converter import SongConverter
+from .html_converter_utils import interpret, replace_in_file
+from .standard_html_converter import StandardHtmlConverter
+
+class KindleHtmlConverter(SongConverter):
+ def _add_chunk(self, chunk, parent, position):
+ """class chunk -> html span chunk"""
+ span_chunk = etree.SubElement(parent, "span", attrib={"class": "chunk"})
+ chord = etree.SubElement(span_chunk, "span", attrib={"class": "chord"})
+ span_ch = etree.SubElement(chord, "span", attrib={"class": "ch"})
+ span_ch.text = chunk.chord
+ span_content = etree.SubElement(span_chunk, "span", attrib={"class": "content"})
+ # Nested spans help with formatting as table.
+ span_content = etree.SubElement(span_content, "span", attrib={"class": "content-i"})
+ if position == 0:
+ if chunk.content.startswith(' '):
+ span_content.text = chunk.content
+ else:
+ span_content.text = ' ' + chunk.content
+ else:
+ if chunk.content=='':
+ span_content.text = ' '
+ else:
+ span_content.text = chunk.content
+
+
+ def _add_lyric(self, row, parent):
+ """class lyric -> html span lyric"""
+ span_lyric = etree.SubElement(parent, "span", attrib={"class": "lyric"})
+ for i, chunk in enumerate(row.chunks):
+ self._add_chunk(chunk, span_lyric, i)
+
+
+ def _add_chords(self, row, parent, class_name):
+ """class chords -> html span ch"""
+ span_chords = etree.SubElement(parent, "span", attrib={"class": class_name})
+ if row.sidechords:
+ for chunk in row.sidechords.split(" "):
+ span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
+ span_ch.text = chunk
+ else:
+ for chunk in row.chunks:
+ if len(chunk.chord) > 0:
+ span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
+ span_ch.text = chunk.chord
+
+ def _add_row(self, row, parent):
+ """class row -> html div row with content"""
+ if (row.new_chords == 0) or (row.new_chords is False):
+ chords_over = "over_false"
+ else:
+ chords_over = "over_true"
+ div_row = etree.SubElement(parent, "div", attrib={"class": "row " + chords_over})
+ div_row.text = u'\u200d' # Not visible spaces -> forces the row to be generated as a single line. We remove it in post-processing.
+ self._add_chords(row, div_row, "chords")
+ self._add_lyric(row, div_row)
+ if str(type(row.bis)) == "" and row.bis is True:
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = u'\u00a0'
+ elif str(type(row.bis)) == "":
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = 'x' + str(row.bis)
+ else:
+ span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
+ span_unbis.text =u'\u00a0'
+
+ def _add_instrumental_row(self, row, parent):
+ """class row instrumental-> html div row with content"""
+ div_row = etree.SubElement(parent, "div", attrib={"class": "row"})
+ if str(type(row.bis)) == "" and row.bis is True:
+ self._add_chords(row, div_row, "chords_ins")
+ _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = u'\u00a0'
+ elif str(type(row.bis)) == "":
+ self._add_chords(row, div_row, "chords_ins")
+ _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = 'x' + str(row.bis)
+ else:
+ self._add_chords(row, div_row, "chords_ins")
+ _ = etree.SubElement(div_row, "span", attrib={"class": "lyric"})
+ span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
+ span_unbis.text = u'\u00a0'
+
+ def _add_block(self, block, parent, block_type, verse_cnt):
+ """class verse -> html div verse/chorus/other with content"""
+ div_verse = etree.SubElement(parent, "div", attrib={"class": block_type})
+
+ for ro in block.rows:
+ if ro.instr:
+ self._add_instrumental_row(ro, div_verse)
+ else:
+ self._add_row(ro, div_verse)
+
+
+ def _add_creator(self, creator, describe, parent):
+ """class creator -> html div creator # metadane piosenki"""
+ div = etree.SubElement(parent, "div", attrib={"class": "creator"})
+ span_label = etree.SubElement(div, "span", attrib={"class": "label"})
+ span_label.text = describe
+ span_content = etree.SubElement(div, "span", attrib={"class": "content_creator"})
+ span_content.text = creator
+
+ def _add_blocks(self, song, parent):
+ body_song = parent.find("body")
+ """class song -> html div body # blok z metadanymi o piosence i piosenką"""
+ h1_title = etree.SubElement(body_song, "h1", attrib={"class": "title", "id": "title"})
+ h1_title.text = song.title
+ if song.original_title:
+ self._add_creator(song.original_title, "Tytuł oryginalny: ", body_song)
+ if song.alias:
+ self._add_creator(song.alias, "Tytuł alternatywny: ", body_song)
+ if song.text_author:
+ self._add_creator(song.text_author, "Słowa: ", body_song)
+ if song.translator:
+ self._add_creator(song.translator, "Tłumaczenie: ", body_song)
+ if song.composer:
+ self._add_creator(song.composer, "Muzyka: ", body_song)
+ if song.music_source:
+ self._add_creator(song.music_source, "Melodia oparta na: ", body_song)
+ if song.artist:
+ self._add_creator(song.artist, "Wykonawca: ", body_song)
+ if song.album:
+ self._add_creator(song.album, "Album: ", body_song)
+ if song.metre:
+ self._add_creator(song.metre, "Metrum: ", body_song)
+ if song.barre and int(song.barre) > 0:
+ self._add_creator(song.barre, "Kapodaster: ", body_song)
+
+ corpse = etree.SubElement(body_song, "div", attrib={"class": "song_body", "id": "song_body"})
+ verse_cnt = 0
+ for block in song.blocks:
+ if block.block_type.value == 'V':
+ b_type = "verse"
+ verse_cnt = verse_cnt + 1
+ elif block.block_type.value == 'C':
+ b_type = "chorus"
+ else:
+ b_type = "other"
+ blockid=""
+ if b_type == "verse":
+ blockid=str(verse_cnt) + "."
+ if b_type == "chorus":
+ blockid="Ref:"
+
+
+ spacer = etree.SubElement(corpse, "div", attrib={"class": "block_spacer"})
+ block_id = etree.SubElement(spacer, "span", attrib={"class": "block_id"})
+ block_id.text = blockid
+ self._add_block(block, corpse, b_type, verse_cnt)
+
+ if song.comment:
+ div = etree.SubElement(body_song, "div", attrib={"class": "comment"})
+ span_content = etree.SubElement(div, "span", attrib={"class": "comment"})
+ span_content.text = song.comment
+
+
+ def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}):
+ xhtml_namespace = "http://www.w3.org/1999/xhtml"
+ epub_namespace = "http://www.idpf.org/2007/ops"
+ xhtml = "{%s}" % xhtml_namespace
+ nsmap = {None: xhtml_namespace,
+ "epub": epub_namespace}
+
+ song = rsx.parse_song_xml(src_xml_path)
+ root_html = etree.Element(xhtml + "html", nsmap=nsmap)
+ root_html.attrib[etree.QName("lang")] = "pl-PL"
+ head = etree.SubElement(root_html, "head")
+ etree.SubElement(head, "link",
+ attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song_common.css", "media": "all"})
+ etree.SubElement(head, "link",
+ attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song_kindle.css", "media": "all"})
+ etree.SubElement(head, "meta", attrib={"name": "viewport", "content": "width=device-width, initial-scale=0.8"})
+
+ for s in song_head:
+ head.append(interpret(copy.deepcopy(s), substitions))
+
+ body = etree.SubElement(root_html, "body", attrib={"class": "song", etree.QName("http://www.idpf.org/2007/ops", "type"):"bodymatter"})
+ for s in song_prefix:
+ body.append(interpret(copy.deepcopy(s), substitions))
+
+ self._add_blocks(song, root_html)
+ title = etree.SubElement(head, "title")
+ title.text = song.title
+ et = etree.ElementTree(root_html)
+ for s in song_suffix:
+ body.append(interpret(copy.deepcopy(s), substitions))
+
+ et.write(path_out, doctype='', pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True)
+
+ replace_in_file(path_out, path_out, lambda s: s.replace(u'\u200d', '').replace(
+ " ", "_").replace(
+ ' ', '_'))
\ No newline at end of file
diff --git a/src/html/song_converter.py b/src/html/song_converter.py
new file mode 100644
index 00000000..9333ccdf
--- /dev/null
+++ b/src/html/song_converter.py
@@ -0,0 +1,7 @@
+from abc import ABC, abstractmethod
+
+class SongConverter(ABC):
+ @abstractmethod
+ def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}):
+ """Convert song from XML to HTML format"""
+ pass
diff --git a/src/html/song_utils.py b/src/html/song_utils.py
new file mode 100644
index 00000000..f07f2a59
--- /dev/null
+++ b/src/html/song_utils.py
@@ -0,0 +1,34 @@
+import os
+
+def create_list_of_songs(song_set):
+ if str(type(song_set)) == "":
+ for i in range(len(song_set)):
+ if song_set[i][-4:] == '.xml':
+ song_set[i] = song_set[i][0:-4]
+ return song_set
+ else:
+ songs_list = os.listdir(song_set)
+ for i in range(len(songs_list)):
+ if songs_list[i][-4:] == '.xml':
+ songs_list[i] = songs_list[i][0:-4]
+ return songs_list
+
+def create_all_songs_html_from_dir(converter, path_in, path_out, song_suffix=[]):
+ return create_all_songs_html(converter, create_list_of_songs(path_in), path_out, song_suffix)
+
+def create_all_songs_html(converter, list_of_songs, path_out, song_suffix=[], song_prefix=[], song_head=[], substitions={}):
+ if not os.path.exists(path_out):
+ os.mkdir(path_out)
+
+ for song in list_of_songs:
+ if not song.is_alias():
+ relative_path = os.path.relpath(song.plik(), start=os.path.join(os.path.dirname(__file__), "../../songs"))
+ converter.xml2html(song.plik(),
+ os.path.join(path_out, song.base_file_name() + '.xhtml'),
+ song_suffix=song_suffix,
+ song_prefix=song_prefix,
+ song_head=song_head,
+ substitions=substitions | {
+ "{{SRC}}": relative_path,
+ "{{BASE_FILENAME}}": song.base_file_name()
+ })
diff --git a/src/html/standard_html_converter.py b/src/html/standard_html_converter.py
new file mode 100644
index 00000000..64d4a014
--- /dev/null
+++ b/src/html/standard_html_converter.py
@@ -0,0 +1,217 @@
+# Standard HTML song converter
+import os
+import copy
+from lxml import etree
+import src.lib.read_song_xml as rsx
+from .song_converter import SongConverter
+from .song_utils import create_list_of_songs, create_all_songs_html, create_all_songs_html_from_dir
+from .html_converter_utils import interpret, replace_in_file
+
+
+class StandardHtmlConverter(SongConverter):
+ def _add_chunk_group(self, chunks, parent, position):
+ """Add a group of chunks that share the same position in text"""
+ # Debug comment showing chunk group content
+ parent.append(etree.Comment(', '.join(str(c).replace(u'-', '_') for c in chunks)))
+
+ # Callect all chords and create chord stack
+ chords = [c.chord for c in chunks if c.chord]
+ # concatenate all content
+ content = ''
+ for c in chunks:
+ if c.content:
+ content += c.content
+ if chords: # If there are any chords
+ span_stack = etree.SubElement(parent, "span", attrib={"class": "ch-stack"})
+ for chord in chords:
+ span_ch = etree.SubElement(span_stack, "span", attrib={
+ "class": "ch",
+ "role": "note",
+ "aria-label": f"chord {chord}"
+ })
+ span_ch.text = chord
+ span_stack.tail = content if content else ''
+ else:
+ # if there is previous element, append to tail
+ last_child = parent[-1] if len(parent) > 0 else None
+ if last_child is not None:
+ last_child.tail = (last_child.tail or '') + content
+ else:
+ parent.text = (parent.text or '') + content
+
+ def _add_lyric(self, row, parent, chords_over_bool):
+ """Add line grouping chunks by position"""
+ div_line = etree.SubElement(parent, "div", attrib={"class": "lyric"})
+
+ if chords_over_bool:
+ i = 0
+ chunks = row.chunks
+ while i < len(chunks):
+ group = []
+ while i < len(chunks) and not chunks[i].content:
+ group.append(chunks[i])
+ i += 1
+ if i < len(chunks):
+ group.append(chunks[i])
+ i += 1
+ self._add_chunk_group(group, div_line, len(div_line))
+ else:
+ t = ''
+ for chunk in row.chunks:
+ t += chunk.content
+ div_line.text = t
+
+
+ def _add_chords(self, row, parent, class_name):
+ """class chords -> html span ch"""
+ span_chords = etree.SubElement(parent, "span", attrib={"class": class_name})
+ if row.sidechords:
+ for chunk in row.sidechords.split(" "):
+ span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
+ span_ch.text = chunk
+ else:
+ for chunk in row.chunks:
+ if len(chunk.chord) > 0:
+ span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"})
+ span_ch.text = chunk.chord
+
+ def _add_instrumental_row(self, row, parent):
+ """class row instrumental-> html div row with content"""
+ div_row = etree.SubElement(parent, "div", attrib={"class": "row"})
+ if str(type(row.bis)) == "" and row.bis is True:
+ self._add_chords(row, div_row, "chords_ins")
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = u'\u00a0'
+ elif str(type(row.bis)) == "":
+ self._add_chords(row, div_row, "chords_ins")
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = 'x' + str(row.bis)
+ else:
+ self._add_chords(row, div_row, "chords_ins")
+ span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
+ span_unbis.text = u'\u00a0'
+
+ def _add_row(self, row, parent):
+ """class row -> html div row with content"""
+ chords_over_bool = not ((row.new_chords == 0) or (row.new_chords is False))
+ chords_over = "over_true" if chords_over_bool else "over_false"
+ div_row = etree.SubElement(parent, "div", attrib={"class": "row " + chords_over})
+ div_row.text = u'\u200d' # Not visible spaces -> forces the row to be generated as a single line. We remove it in post-processing.
+ self._add_chords(row, div_row, "chords")
+ self._add_lyric(row, div_row, chords_over_bool)
+ if str(type(row.bis)) == "" and row.bis is True:
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = u'\u00a0'
+ elif str(type(row.bis)) == "":
+ span_bis = etree.SubElement(div_row, "span", attrib={"class": "bis_active"})
+ span_bis.text = 'x' + str(row.bis)
+ else:
+ span_unbis = etree.SubElement(div_row, "span", attrib={"class": "bis_inactive"})
+ span_unbis.text =u'\u00a0'
+
+ def _add_block(self, block, parent, block_type, verse_cnt):
+ """Add verse with grid layout structure"""
+ div_verse = etree.SubElement(parent, "div", attrib={"class": block_type})
+
+ for ro in block.rows:
+ if ro.instr:
+ self._add_instrumental_row(ro, div_verse)
+ else:
+ self._add_row(ro, div_verse)
+
+ def _add_creator(self, creator, describe, parent):
+ """class creator -> html div creator # metadane piosenki"""
+ div = etree.SubElement(parent, "div", attrib={"class": "creator"})
+ span_label = etree.SubElement(div, "span", attrib={"class": "label"})
+ span_label.text = describe
+ span_content = etree.SubElement(div, "span", attrib={"class": "content_creator"})
+ span_content.text = creator
+
+ def _add_blocks(self, song, parent):
+ body_song = parent.find("body")
+ """class song -> html div body # blok z metadanymi o piosence i piosenką"""
+ h1_title = etree.SubElement(body_song, "h1", attrib={"class": "title", "id": "title"})
+ h1_title.text = song.title
+ if song.original_title:
+ self._add_creator(song.original_title, "Tytuł oryginalny: ", body_song)
+ if song.alias:
+ self._add_creator(song.alias, "Tytuł alternatywny: ", body_song)
+ if song.text_author:
+ self._add_creator(song.text_author, "Słowa: ", body_song)
+ if song.translator:
+ self._add_creator(song.translator, "Tłumaczenie: ", body_song)
+ if song.composer:
+ self._add_creator(song.composer, "Muzyka: ", body_song)
+ if song.music_source:
+ self._add_creator(song.music_source, "Melodia oparta na: ", body_song)
+ if song.artist:
+ self._add_creator(song.artist, "Wykonawca: ", body_song)
+ if song.album:
+ self._add_creator(song.album, "Album: ", body_song)
+ if song.metre:
+ self._add_creator(song.metre, "Metrum: ", body_song)
+ if song.barre and int(song.barre) > 0:
+ self._add_creator(song.barre, "Kapodaster: ", body_song)
+
+ corpse = etree.SubElement(body_song, "div", attrib={"class": "song_body", "id": "song_body"})
+ verse_cnt = 0
+ for block in song.blocks:
+ if block.block_type.value == 'V':
+ b_type = "verse"
+ verse_cnt = verse_cnt + 1
+ elif block.block_type.value == 'C':
+ b_type = "chorus"
+ else:
+ b_type = "other"
+ blockid=""
+ if b_type == "verse":
+ blockid=str(verse_cnt) + "."
+ if b_type == "chorus":
+ blockid="Ref:"
+
+
+ spacer = etree.SubElement(corpse, "div", attrib={"class": "block_spacer"})
+ block_id = etree.SubElement(spacer, "span", attrib={"class": "block_id"})
+ block_id.text = blockid
+ self._add_block(block, corpse, b_type, verse_cnt)
+
+ if song.comment:
+ div = etree.SubElement(body_song, "div", attrib={"class": "comment"})
+ span_content = etree.SubElement(div, "span", attrib={"class": "comment"})
+ span_content.text = song.comment
+
+
+ def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}):
+ xhtml_namespace = "http://www.w3.org/1999/xhtml"
+ epub_namespace = "http://www.idpf.org/2007/ops"
+ xhtml = "{%s}" % xhtml_namespace
+ nsmap = {None: xhtml_namespace,
+ "epub": epub_namespace}
+
+ song = rsx.parse_song_xml(src_xml_path)
+ root_html = etree.Element(xhtml + "html", nsmap=nsmap)
+ root_html.attrib[etree.QName("lang")] = "pl-PL"
+ head = etree.SubElement(root_html, "head")
+ etree.SubElement(head, "link",
+ attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song_common.css", "media": "all"})
+ etree.SubElement(head, "link",
+ attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song.css", "media": "all"})
+ etree.SubElement(head, "meta", attrib={"name": "viewport", "content": "width=device-width, initial-scale=0.8"})
+
+ for s in song_head:
+ head.append(interpret(copy.deepcopy(s), substitions))
+
+ body = etree.SubElement(root_html, "body", attrib={"class": "song", etree.QName("http://www.idpf.org/2007/ops", "type"):"bodymatter"})
+ for s in song_prefix:
+ body.append(interpret(copy.deepcopy(s), substitions))
+
+ self._add_blocks(song, root_html)
+ title = etree.SubElement(head, "title")
+ title.text = song.title
+ et = etree.ElementTree(root_html)
+ for s in song_suffix:
+ body.append(interpret(copy.deepcopy(s), substitions))
+
+ et.write(path_out, doctype='', pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True)
+
+ replace_in_file(path_out, path_out, lambda s: s.replace(u'\u200d', ''))
diff --git a/src/lib/read_song_xml.py b/src/lib/read_song_xml.py
index bff97f0f..ffa6f43b 100644
--- a/src/lib/read_song_xml.py
+++ b/src/lib/read_song_xml.py
@@ -11,6 +11,9 @@ def __init__(self, chord='', content=None):
else:
self.content = content.strip('\t\n')
+ def __str__(self):
+ return f"[chord='{self.chord}' content='{self.content}']"
+
class RowType(Enum):
FIRST = 'F'