Guitar-Sheet-Parser/lib/dataStructures.py
Marco van Dijk bf0352a600 Added optimalisation to resize down to fit whitespace better
Also increased default font size, since we resize down if needed
A lower default font size will speed up the program
fixes #8
2021-07-09 16:20:11 +02:00

394 lines
15 KiB
Python

#!/usr/bin/env python3
##
# @file dataStructures.py
#
# @brief This file contains the internal data structures required for each tablature file
#
# @section description Description
# -
#
# @section notes Notes
#
# @section todo TODO
# - Move helper functions like stripEmptyLines to a separate file for
# - Move read functions to separate input functions (also to support more types of inputs)
import re
import lib.config
from PIL import ImageFont
"""!@brief Removes empty lines and makes sure every line ends with \r\n
@param inputString raw txt input
@return string of parsed input
"""
def stripEmptyLines(inputString):
nonEmptyLines = ""
lines = inputString.split("\n")
for line in lines:
if line.strip() != "":
nonEmptyLines += line + "\r\n"
return nonEmptyLines
"""!@brief Opens a .txt file and loads it's contents into buffer
@param inputFile path to .txt file
@return .txt file raw contents
"""
def readSourceFile(inputFile):
with open(inputFile, 'r') as file:
return file.read()
"""!@brief Returns whether the string is a line of lyrics or a line of tablature data
@param inputString single line of text
@return True if it is tablature data, False if it is lyric data
"""
def isTablatureData(inputString):
if not inputString:
return
#print("Checking '{}' for line type".format(inputString))
# Assume tablature line if any character {/, #, (, ), }
tablatureSpecificCharacterString = r"/#"
if any(elem in inputString for elem in tablatureSpecificCharacterString):
#print("'{}' is a tablature line, since it contains a tablature specific character".format(inputString))
return True
# Assume LYRIC line if any TEXT character OTHER THAN {a, b, c, d, e, f, g, h, b, x, m}
lyricSpecificCharacterString = r"abcdefghbxm"
for char in inputString:
if char.isalpha():
if not char.lower() in lyricSpecificCharacterString:
#print("'{}' is a LYRIC line, since it contains lyric specific text characters".format(inputString))
return False
# Assume tablature line if any digit
if any(char.isdigit() for char in inputString):
#print("'{}' is a tablature line, since it contains a number".format(inputString))
return True
# Assume LYRIC line if any character {.}
lyricSpecialChars = r"."
if any(elem in inputString for elem in lyricSpecialChars):
#print("'{}' is a LYRIC line, since it contains lyric specific special characters".format(inputString))
return False
# Else warn and assume tablature line
#print("Unable to identify if '{}' is a lyric or tablature line. Assuming it is a tablature line. Please improve the isTablatureData function".format(inputString))
return True
"""!@brief Class containing Section specific data
"""
class Section:
def __init__(self):
# List of lines of lyrics strings
self.lyrics = []
# List of lines of tablature strings
self.tablatures = []
# section type string
self.header = ""
# string of tablature and lyric data
self.rawData = ""
# Flag for succesfully parsed
self.isParsed = False
# Expected dimensions of this section
self.expectedWidth = -1
self.expectedHeight = -1
"""!@brief Calculates dimensions of rendered text
@return None
"""
def calculateSectionDimensions(self, fontTablature, fontLyrics):
lineIterator = 0
amountOfLines = len(self.lyrics)
heightSum = 0
maxWidth = 0
# consider section title
headerWidth, headerHeight = fontTablature.getsize(self.header)
heightSum += headerHeight
maxWidth = headerWidth
#print("With header, dimensions of section '{}' start at {}H{}B".format(self.header[:-2], heightSum, maxWidth))
while lineIterator < amountOfLines:
# Get chord&lyric line dimensions
lyricTextWidth, lyricTextHeight = fontLyrics.getsize(self.lyrics[lineIterator])
tablatureTextWidth, chordTextHeight = fontTablature.getsize(self.tablatures[lineIterator])
heightSum += lyricTextHeight + chordTextHeight
if lyricTextWidth > maxWidth:
maxWidth = lyricTextWidth
if tablatureTextWidth > maxWidth:
maxWidth = tablatureTextWidth
lineIterator += 1
self.expectedWidth = maxWidth
self.expectedHeight = heightSum
"""!@brief Converts raw buffered data into separate Lyric and tablature lines
@return None
"""
# Parses self.rawData into lyrics and tablature strings
def initSections(self):
isFirstLine = True
# Input sections may have tablature-only or lyric-only sections
# So we have to insert empty lines if we have subsequent tablature or lyric lines
lines = self.rawData.split('\r\n')
for line in lines:
if not len(line):
continue
# Determine lyric or tablature line
currentIsTablature = isTablatureData(line)
#print("Have line {} isTab={}, isLyric={}".format(line, currentIsTablature, not currentIsTablature))
# Initially just fill in the first line correctly
if isFirstLine:
isFirstLine = False
if currentIsTablature:
self.tablatures.append(line)
else:
self.lyrics.append(line)
# We want alternating lines, so if the prev is of the same type
# we need to insert an empty line of the other type
elif currentIsTablature == prevWasTablature:
if currentIsTablature:
#print("Inserting empty Lyric line")
self.tablatures.append(line)
self.lyrics.append("")
else:
#print("Inserting empty tablature line")
self.lyrics.append(line)
self.tablatures.append("")
# also insert the current line
elif currentIsTablature:
#print("Inserting empty Lyric line")
self.tablatures.append(line)
else:
self.lyrics.append(line)
# move on to next line, save current type
prevWasTablature = currentIsTablature
# Simple check to see if it probably exported correctly
if abs(len(self.lyrics) - len(self.tablatures)) > 1:
print("Unable to parse section {}, since there is a mismatch between the amount of lyrics ({}) and tablature ({}) lines.".format(self.header, len(self.lyrics), len(self.tablatures)))
return
# Add a trailing empty line if necessary
elif len(self.lyrics) > len(self.tablatures):
self.tablatures.append("")
elif len(self.lyrics) < len(self.tablatures):
self.lyrics.append("")
self.isParsed = True
"""!@brief Class containing Sections which fit on 1 page
"""
class Page:
def __init__(self):
self.sections = []
self.totalHeight = -1
"""!@brief Class containing Song specific data
"""
class Song:
def __init__(self):
# Src file
self.inputFile = ""
# Path to folder
self.outputLocation = ""
# Title - based on input file
self.title = ""
# List of Section objects
self.sections = []
# Meta info: the text before the first section
self.metadata = ""
self.metadataWidth = -1
self.metadataHeight = -1
# String of entire input
self.rawData = ""
# List of pages, which contain sections which fit on a page
self.pages = []
# Flag for succesfully parsed
self.isParsed = False
configObj = lib.config.config['output']
self.topMargin = int(configObj['topMargin'])
self.fontColour = tuple(int(var) for var in configObj['fontColour'].split(','))
self.backgroundColour = tuple(int(var) for var in configObj['backgroundColour'].split(','))
self.metadataColour = tuple(int(var) for var in configObj['metadataColour'].split(','))
self.imageWidth = int(configObj['imageWidth'])
self.imageHeight = int(configObj['imageHeight'])
self.leftMargin = int(configObj['leftMargin'])
self.rightMargin = int(configObj['rightMargin'])
self.fontMetadata = ImageFont.truetype(configObj['metafontfamily'], int(configObj['metaFontWeight']))
self.fontSize = int(configObj['songFontWeight'])
self.fontLyrics = ImageFont.truetype(configObj['lyricfontfamily'], self.fontSize)
self.fontTablature = ImageFont.truetype(configObj['tablaturefontfamliy'], self.fontSize)
self.configObj = configObj
"""!@brief Calculates dimensions of metadata
@param section lib.dataStructures.Section object
@return None
"""
def calculateMetadataDimensions(self):
# metadata starts topMargin removed from top
currentHeight = self.topMargin
maxWidth = 0
for line in self.metadata.split('\n'):
line = line.rstrip()
if not line:
continue
metadataTextWidth, metadataTextHeight = self.fontMetadata.getsize(line)
if metadataTextWidth > maxWidth:
maxWidth = metadataTextWidth
currentHeight += metadataTextHeight
self.metadataWidth = maxWidth
self.metadataHeight = currentHeight
#print("metadata dimensions are {}h : {}w".format(currentHeight, maxWidth))
"""!@brief Resizes all sections by a specified amount
Also recalculates all section sizes afterwards
@param mutator amount of fontSize to add/dec from current font size
@return None
"""
def resizeAllSections(self, mutator):
print("Resizing font by {} to {}".format(mutator, self.fontSize))
self.fontSize += mutator
self.fontLyrics = ImageFont.truetype(self.configObj['lyricfontfamily'], self.fontSize)
self.fontTablature = ImageFont.truetype(self.configObj['tablaturefontfamliy'], self.fontSize)
self.prerenderSections()
"""!@brief Calculates the expected dimensions of all sections
@return None
"""
def prerenderSections(self):
self.calculateMetadataDimensions()
for section in self.sections:
section.calculateSectionDimensions(self.fontTablature, self.fontLyrics)
"""!@brief Calculates the expected dimensions of all sections
@return None
"""
def fitSectionsByWidth(self):
self.prerenderSections()
while not self.checkOverflowX():
#print("Resizing down to prevent overflow on the width of the page")
self.resizeAllSections(-1)
"""!@brief Checks whether we are overflowing on the width of the page
@return True if everything OK, False if overflowing
"""
def checkOverflowX(self):
for section in self.sections:
if section.expectedWidth > self.imageWidth - self.leftMargin - self.rightMargin:
print("There is an overflow on width: this section has a width of {}, but we have {} ({}-{}-{}) amount of space".format(section.expectedWidth, self.imageWidth - self.leftMargin - self.rightMargin, self.imageWidth, self.leftMargin, self.rightMargin))
return False
return True
"""!@brief Tries to fill in the whitespace on the current render
It will compare the size of existing whitespace with the size of the first section on the next page
While the amount we are short is within 10% of the current image height, resize down
@return True if we should resize down, False if we are fine
"""
def canFillWhitespace(self):
amountOfPages = len(self.pages)
currentPageIt = 0
if not amountOfPages:
return False
# get first section on next page, if we have a next page to begin with
while currentPageIt < amountOfPages - 1:
curPage = self.pages[currentPageIt]
nextPage = self.pages[currentPageIt + 1]
nextFirstSection = nextPage.sections[0]
whitespace = self.imageHeight - curPage.totalHeight
amountWeAreShort = nextFirstSection.expectedHeight - whitespace
shortInPercentages = amountWeAreShort / self.imageHeight
# Take a 10% range
#print("Whitespace {} vs next section height {}".format(whitespace, nextFirstSection.expectedHeight))
#print("We are {} short to fit the next image (total image height {} => {}% of total height)".format(amountWeAreShort, self.imageHeight, shortInPercentages*100))
if shortInPercentages < 0.10:
return True
currentPageIt += 1
return False
"""!@brief Fits current sections into pages
@return None
"""
def sectionsToPages(self):
self.prerenderSections()
self.pages = []
# First page contains metadata
currentHeight = self.topMargin
currentHeight += self.metadataHeight
currentHeight += self.topMargin
curPage = Page()
# Now fit all sections
for section in self.sections:
if (section.expectedHeight == -1 or section.expectedWidth == -1):
print("Warning: this file was not processed correctly. The expected dimensions are not set")
# See if the section would fit on the current page - if it does not, we have a filled page
if currentHeight + section.expectedHeight > self.imageHeight:
curPage.totalHeight = currentHeight
self.pages.append(curPage)
currentHeight = self.topMargin
curPage = Page()
# Add setion header size and size of lines of data
headerWidth, headerHeight = self.fontTablature.getsize(section.header)
currentHeight += headerHeight
currentHeight += section.expectedHeight
curPage.sections.append(section)
# Margin between each section
currentHeight += self.topMargin
# No more sections left, so the current buffered image is ready to be written to file
curPage.totalHeight = currentHeight
self.pages.append(curPage)
"""!@brief Parses self.rawData into Section objects and metadata
@return None
"""
def initSections(self):
# Get raw data
self.rawData = readSourceFile(self.inputFile)
# Clean up input
parseData = stripEmptyLines(self.rawData)
#print("Clean data='{}'\n".format(parseData))
# While not EOF: build sections untill new section found.
delimiterIndex = parseData.find("[")
if delimiterIndex == -1:
print("Cannot parse input file, since it is not delimited by '[<sectionName>]' entries")
return
# Start with metadata
self.metadata = parseData[:delimiterIndex]
#print("Set '{}' as metadata".format(self.metadata))
parseData = parseData[delimiterIndex:]
# We are now at the start of the first section, at the '[' character
while parseData:
# Init new Section object
thisSection = Section()
# Get header on the first line
delimiterIndex = parseData.find("]\r\n")
if delimiterIndex == -1:
print("Cannot parse input file, delimitor did not match '[<sectionName>]'")
return
# Skip the ']\r\n' characters
thisSection.header = parseData[:delimiterIndex+3]
parseData = parseData[delimiterIndex+3:]
# Find next section
delimiterIndex = parseData.find("[")
# If EOF, current buffer is final section
if delimiterIndex == -1:
# Set thisSection's data to remaining buffer
thisSection.rawData = parseData
parseData = ""
else:
# Set thisSection's data and remove it from the buffer
thisSection.rawData = parseData[:delimiterIndex]
#print("set rawData of '{}' to this section".format(thisSection.rawData))
parseData = parseData[delimiterIndex:]
# Finally parse section data
thisSection.initSections()
if thisSection.isParsed:
self.sections.append(thisSection)
else:
print("Aborting parse due to section not being parseable.")
return
self.isParsed = True