mirror of
https://github.com/stronk-dev/Guitar-Sheet-Parser.git
synced 2025-07-05 08:25:09 +02:00
544 lines
22 KiB
Python
544 lines
22 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
|
|
|
|
A4 = {'width': 210, 'height': 297}
|
|
A5 = {'width': 210, 'height': 148}
|
|
|
|
"""!@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, keepEmptyLines):
|
|
nonEmptyLines = ""
|
|
lines = inputString.split("\n")
|
|
for line in lines:
|
|
if keepEmptyLines or 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, j, n}
|
|
lyricSpecificCharacterString = r"abcdefghbxmjn"
|
|
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.splitlines(True)
|
|
for line in lines:
|
|
# Empty line whitespace
|
|
if not line:
|
|
self.lyrics.append("")
|
|
self.tablatures.append("")
|
|
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 = ""
|
|
self.fileExtension = ""
|
|
# 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.ppi = int(configObj['imageppi'])
|
|
# 0.03937 pixels per minimeter per ppi
|
|
self.imageWidth = int(self.ppi * A4['width'] * 0.03937)
|
|
self.imageHeight = int(self.ppi * A4['height'] * 0.03937)
|
|
# With a PPI of 72, a font size of 14-18 is a good starting point (PPI / 4 or 4.5)
|
|
# Since font size is then shrunk and grown to fit whitespace we do not need to be as accurate
|
|
# PPI of 144 -> fontSize of 32
|
|
self.fontSize = int(self.ppi / 4.5)
|
|
self.leftMargin = int(configObj['leftMargin'])
|
|
self.rightMargin = int(configObj['rightMargin'])
|
|
self.fontLyrics = ImageFont.truetype(configObj['lyricfontfamily'], self.fontSize)
|
|
self.fontTablature = ImageFont.truetype(configObj['tablaturefontfamliy'], self.fontSize)
|
|
self.fontFamilyLyrics = configObj['lyricfontfamily']
|
|
self.fontFamilyTablature = configObj['tablaturefontfamliy']
|
|
self.metadataFontsize = int(configObj['metaFontWeight'])
|
|
self.metadataFontFamily = configObj['metafontfamily']
|
|
self.fontMetadata = ImageFont.truetype(self.metadataFontFamily, self.metadataFontsize)
|
|
# Allowed whitespace to total width ratios. Makes stuff smaller but fit on less pages, probably
|
|
# percentage of missing whitespace on total page height it wants before it tries to resize down
|
|
self.tryToShrinkRatio = float(configObj['tryToShrinkRatio'])
|
|
# Setting this makes sure that the largest section on the page fills at least this percentage of total width
|
|
# The higher this is, the more it is allowed to shrink
|
|
self.lowestWhitespaceOnWidthRatioAllowed = float(configObj['lowestWhitespaceOnWidthRatioAllowed'])
|
|
# Some sections are very small, so the highest whitespace can be very large.
|
|
# It is advised to keep this value relatively small
|
|
self.hightestWhitespaceOnWidthRatioAllowed = float(configObj['hightestWhitespaceOnWidthRatioAllowed'])
|
|
# Strip empty lines from input or keep em
|
|
self.keepEmptyLines = configObj['keepEmptyLines'] == '1'
|
|
|
|
|
|
"""!@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.fontFamilyLyrics, self.fontSize)
|
|
self.fontTablature = ImageFont.truetype(self.fontFamilyTablature, self.fontSize)
|
|
self.prerenderSections()
|
|
|
|
"""!@brief Resizes metadata and recalcs its size
|
|
@param mutator amount of fontSize to add/dec from current font size
|
|
@return None
|
|
"""
|
|
def resizeMetadata(self, mutator):
|
|
self.metadataFontsize += mutator
|
|
self.fontMetadata = ImageFont.truetype(self.metadataFontFamily, self.metadataFontsize)
|
|
self.calculateMetadataDimensions()
|
|
|
|
"""!@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)
|
|
while not self.checkOverflowMetadata():
|
|
#print("Resizing down to prevent overflow on the width of the page")
|
|
self.resizeMetadata(-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 Checks whether the metadata is overflowing on the width of the page
|
|
@return True if everything OK, False if overflowing
|
|
"""
|
|
def checkOverflowMetadata(self):
|
|
if self.metadataWidth > self.imageWidth - self.leftMargin - self.rightMargin:
|
|
return False
|
|
return True
|
|
|
|
"""!@brief Checks whether we can increase the font size without creating more pages
|
|
@return None
|
|
"""
|
|
def increaseWhileSameAmountOfPages(self):
|
|
targetPageAmount = len(self.pages)
|
|
originalFontsize = self.fontSize
|
|
self.resizeAllSections(1)
|
|
self.sectionsToPages()
|
|
currentPageAmount = len(self.pages)
|
|
# Increase fontSize as long as we do not add a page
|
|
while currentPageAmount <= targetPageAmount and self.checkOverflowX():
|
|
self.resizeAllSections(+1)
|
|
self.sectionsToPages()
|
|
currentPageAmount = len(self.pages)
|
|
# Now undo latest increase to go back to target page amount
|
|
self.resizeAllSections(-1)
|
|
self.sectionsToPages()
|
|
currentPageAmount = len(self.pages)
|
|
if targetPageAmount != currentPageAmount:
|
|
print("Oops! While resizing up we changed the amount of pages from {} to {}".format(targetPageAmount, currentPageAmount))
|
|
if self.fontSize != originalFontsize:
|
|
print("Managed to change the font size from {} to {}".format(originalFontsize, self.fontSize))
|
|
|
|
|
|
"""!@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 X% 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
|
|
# Stop resizing if we are creating too much widespace on the width
|
|
smallestWhitespace = self.imageHeight
|
|
biggestWhitespace = -1
|
|
for page in self.pages:
|
|
for section in page.sections:
|
|
whitespaceOnWidth = self.imageWidth - self.leftMargin - self.rightMargin - section.expectedWidth
|
|
if whitespaceOnWidth < smallestWhitespace:
|
|
smallestWhitespace = whitespaceOnWidth
|
|
if whitespaceOnWidth > biggestWhitespace:
|
|
biggestWhitespace = whitespaceOnWidth
|
|
# Sections vary in width, some are very small to begin with
|
|
# Since (almost empty) lines will result in large whitespace sizes, we are less strict on checking that
|
|
if biggestWhitespace / self.imageWidth > self.lowestWhitespaceOnWidthRatioAllowed:
|
|
print("Stopping resizing down, since the smallest section has {}% whitespace on the width of the image".format((biggestWhitespace / self.imageWidth )* 100))
|
|
return False
|
|
# But the largest section on the page should be able to fit at least half of the available page
|
|
if smallestWhitespace / self.imageWidth > self.hightestWhitespaceOnWidthRatioAllowed:
|
|
print("Stopping resizing down, since we largest section has {}% whitespace on the width of the image".format((smallestWhitespace / self.imageWidth )* 100))
|
|
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
|
|
#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))
|
|
# Since we also resize based on minimum required whitespaces, we can be a bit more aggressive with this
|
|
if shortInPercentages < self.tryToShrinkRatio:
|
|
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
|
|
Assumes the raw data is preprocessed, so it parses it using set rules instead of guessing line attributes
|
|
@return None
|
|
"""
|
|
def initPreprocessed(self):
|
|
# Get raw data
|
|
self.rawData = readSourceFile(self.inputFile)
|
|
parseData = self.rawData
|
|
# While not EOF: build sections until 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
|
|
lines = parseData.splitlines(True)
|
|
if not len(lines):
|
|
return
|
|
#print("We found {} lines of data".format(len(lines)))
|
|
# Init first section by popping the delimiter
|
|
thisSection = Section()
|
|
thisSection.header = lines.pop(0)
|
|
# First line is always tab->lyric
|
|
isTabLine = True
|
|
print("First header is '{}'".format(thisSection.header))
|
|
for line in lines:
|
|
# If it is a [header], it is a new section
|
|
if line[0] == '[':
|
|
# Store prev section
|
|
thisSection.initSections()
|
|
if thisSection.isParsed:
|
|
self.sections.append(thisSection)
|
|
else:
|
|
print("Aborting parse due to section not being parseable.")
|
|
return
|
|
# Reset, new section
|
|
thisSection = Section()
|
|
thisSection.header = line
|
|
#print("Header is '{}'".format(thisSection.header))
|
|
isTabLine = True
|
|
# Else is has lines in order tabline->lyricline->repeat
|
|
elif isTabLine:
|
|
#print("Adding Tabline is '{}'".format(line))
|
|
thisSection.tablatures.append(line)
|
|
isTabLine = False
|
|
else:
|
|
#print("Adding Lyricline is '{}'".format(line))
|
|
thisSection.lyrics.append(line)
|
|
isTabLine = True
|
|
# Add final 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
|
|
|
|
"""!@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, self.keepEmptyLines)
|
|
#print("Clean data='{}'\n".format(parseData))
|
|
# While not EOF: build sections until 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, delimiter 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|