diff --git a/_version.py b/_version.py index 986861a..5e1bb30 100644 --- a/_version.py +++ b/_version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- appName = "Chordsheet" -version = '0.4dev' +version = '0.4' diff --git a/chordsheet/comboBox.py b/chordsheet/comboBox.py new file mode 100644 index 0000000..9b8e185 --- /dev/null +++ b/chordsheet/comboBox.py @@ -0,0 +1,12 @@ +from PyQt5.QtWidgets import QComboBox +from PyQt5.QtCore import pyqtSignal + +class MComboBox(QComboBox): + """ + Modified version of combobox that emits a signal with the current item when clicked. + """ + clicked = pyqtSignal(str) + + def showPopup(self): + self.clicked.emit(self.currentText()) + super().showPopup() \ No newline at end of file diff --git a/chordsheet/document.py b/chordsheet/document.py index 5c63088..c636c92 100644 --- a/chordsheet/document.py +++ b/chordsheet/document.py @@ -7,36 +7,41 @@ from reportlab.lib.pagesizes import A4 defaultTimeSignature = 4 + class Style: def __init__(self, **kwargs): # set up the style using sane defaults self.unit = kwargs.get('unit', mm) - self.pageSize = kwargs.get('pageSize', A4) self.leftMargin = kwargs.get('leftMargin', 10) self.topMargin = kwargs.get('topMargin', 10) + self.rightMargin = kwargs.get('rightMargin', 10) + self.bottomMargin = kwargs.get('bottomMargin', 10) self.font = kwargs.get('font', 'FreeSans') self.lineSpacing = kwargs.get('lineSpacing', 1.15) - self.separatorSize = kwargs.get('separatorSize', 5) self.unitWidth = kwargs.get('unitWidth', 10) - + self.useIncludedFont = True - + + self.separatorSize = 5*self.unit + self.stringHzSp = 20*self.unit self.stringHzGap = 2*self.unit self.stringHeight = 5*self.unit self.unitHeight = 20*self.unit self.beatsHeight = 5*self.unit - + self.titleFontSize = 24 self.subtitleFontSize = 18 self.creditsFontSize = 12 self.tempoFontSize = 12 + self.headingFontSize = 18 self.notesFontSize = 12 self.chordNameFontSize = 18 self.beatsFontSize = 12 - + + class Chord: def __init__(self, name, **kwargs): self.name = name @@ -51,21 +56,33 @@ class Chord: class Block: - def __init__(self, length, **kwargs): + def __init__(self, length, chord=None, notes=None): self.length = length - self.chord = kwargs.get('chord', None) - self.notes = kwargs.get('notes', None) + self.chord = chord + self.notes = notes def __eq__(self, other): if isinstance(other, self.__class__): return self.length == other.length and self.chord == other.chord and self.notes == other.notes - return NotImplemented + return NotImplemented + + +class Section: + def __init__(self, blockList=None, name=None): + self.blockList = blockList or [] + self.name = name + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.blockList == other.blockList and self.name == other.name + return NotImplemented + class Document: - def __init__(self, chordList=None, blockList=None, title=None, subtitle=None, composer=None, arranger=None, timeSignature=defaultTimeSignature, tempo=None): + def __init__(self, chordList=None, sectionList=None, title=None, subtitle=None, composer=None, arranger=None, timeSignature=defaultTimeSignature, tempo=None): self.chordList = chordList or [] - self.blockList = blockList or [] - self.title = title or '' # Do not initialise title empty + self.sectionList = sectionList or [] + self.title = title or '' # Do not initialise title empty self.subtitle = subtitle self.composer = composer self.arranger = arranger @@ -74,98 +91,119 @@ class Document: def __eq__(self, other): if isinstance(other, self.__class__): - textEqual = self.title == other.title and self.subtitle == other.subtitle and self.composer == other.composer and self.arranger == other.arranger and self.timeSignature == other.timeSignature and self.tempo == other.tempo # check all the text values for equality - return textEqual and self.chordList == other.chordList and self.blockList == other.blockList + textEqual = self.title == other.title and self.subtitle == other.subtitle and self.composer == other.composer and self.arranger == other.arranger and self.timeSignature == other.timeSignature and self.tempo == other.tempo # check all the text values for equality + return textEqual and self.chordList == other.chordList and self.sectionList == other.sectionList return NotImplemented - + def loadXML(self, filepath): """ Read an XML file and import its contents. """ xmlDoc = ET.parse(filepath) root = xmlDoc.getroot() - + self.chordList = [] - if root.find('chords') is not None: + if root.find('chords'): for c in root.findall('chords/chord'): self.chordList.append(Chord(parseName(c.find('name').text))) for v in c.findall('voicing'): - self.chordList[-1].voicings[v.attrib['instrument']] = parseFingering(v.text, v.attrib['instrument']) - - self.blockList = [] - if root.find('progression') is not None: - for b in root.findall('progression/block'): - blockChordName = parseName(b.find('chord').text) if b.find('chord') is not None else None - if blockChordName: - blockChord = None - for c in self.chordList: - if c.name == blockChordName: - blockChord = c - break - if blockChord is None: - exit("Chord {c} does not match any chord in {l}.".format(c=blockChordName, l=self.chordList)) - else: - blockChord = None - blockNotes = (b.find('notes').text if b.find('notes') is not None else None) - self.blockList.append(Block(int(b.find('length').text), chord=blockChord, notes=blockNotes)) - - self.title = (root.find('title').text if root.find('title') is not None else '') # Do not initialise title empty - self.subtitle = (root.find('subtitle').text if root.find('subtitle') is not None else None) - self.composer = (root.find('composer').text if root.find('composer') is not None else None) - self.arranger = (root.find('arranger').text if root.find('arranger') is not None else None) - self.timeSignature = (int(root.find('timesignature').text) if root.find('timesignature') is not None else defaultTimeSignature) - self.tempo = (root.find('tempo').text if root.find('tempo') is not None else None) - - def newFromXML(self, filepath): + self.chordList[-1].voicings[v.attrib['instrument'] + ] = parseFingering(v.text, v.attrib['instrument']) + + self.sectionList = [] + if root.find('section'): + for n, s in enumerate(root.findall('section')): + blockList = [] + + for b in s.findall('block'): + blockChordName = parseName(b.find('chord').text) if b.find( + 'chord') is not None else None + if blockChordName: + blockChord = None + for c in self.chordList: + if c.name == blockChordName: + blockChord = c + break + if blockChord is None: + exit("Chord {c} does not match any chord in {l}.".format( + c=blockChordName, l=self.chordList)) + else: + blockChord = None + blockNotes = (b.find('notes').text if b.find( + 'notes') is not None else None) + blockList.append( + Block(float(b.find('length').text), chord=blockChord, notes=blockNotes)) + # automatically name the section by its index if a name isn't given. The +1 is because indexing starts from 0. + self.sectionList.append(Section(blockList=blockList, name=( + s.attrib['name'] if 'name' in s.attrib else "Section {}".format(n + 1)))) + + self.title = (root.find('title').text if root.find( + 'title') is not None else '') # Do not initialise title empty + self.subtitle = (root.find('subtitle').text if root.find( + 'subtitle') is not None else None) + self.composer = (root.find('composer').text if root.find( + 'composer') is not None else None) + self.arranger = (root.find('arranger').text if root.find( + 'arranger') is not None else None) + self.timeSignature = (int(root.find('timesignature').text) if root.find( + 'timesignature') is not None else defaultTimeSignature) + self.tempo = (root.find('tempo').text if root.find( + 'tempo') is not None else None) + + def newFromXML(filepath): """ Create a new Document object directly from an XML file. """ doc = Document() doc.loadXML(filepath) return doc - + def saveXML(self, filepath): """ Write the contents of the Document object to an XML file. """ root = ET.Element("chordsheet") - + ET.SubElement(root, "title").text = self.title - + if self.subtitle is not None: ET.SubElement(root, "subtitle").text = self.subtitle if self.arranger is not None: ET.SubElement(root, "arranger").text = self.arranger - + if self.composer is not None: ET.SubElement(root, "composer").text = self.composer - + ET.SubElement(root, "timesignature").text = str(self.timeSignature) if self.tempo is not None: ET.SubElement(root, "tempo").text = self.tempo - + chordsElement = ET.SubElement(root, "chords") - + for c in self.chordList: chordElement = ET.SubElement(chordsElement, "chord") ET.SubElement(chordElement, "name").text = c.name for inst in c.voicings.keys(): if inst == 'guitar': - ET.SubElement(chordElement, "voicing", attrib={'instrument':'guitar'}).text = ','.join(c.voicings['guitar']) + ET.SubElement(chordElement, "voicing", attrib={ + 'instrument': 'guitar'}).text = ','.join(c.voicings['guitar']) if inst == 'piano': - ET.SubElement(chordElement, "voicing", attrib={'instrument':'piano'}).text = c.voicings['piano'][0] # return first element of list as feature has not been implemented - - progressionElement = ET.SubElement(root, "progression") - - for b in self.blockList: - blockElement = ET.SubElement(progressionElement, "block") - ET.SubElement(blockElement, "length").text = str(b.length) - if b.chord is not None: - ET.SubElement(blockElement, "chord").text = b.chord.name - if b.notes is not None: - ET.SubElement(blockElement, "notes").text = b.notes - + # return first element of list as feature has not been implemented + ET.SubElement(chordElement, "voicing", attrib={ + 'instrument': 'piano'}).text = c.voicings['piano'][0] + + for n, s in enumerate(self.sectionList): + sectionElement = ET.SubElement(root, "section", attrib={ + 'name': s.name if s.name else "Section {}".format(n + 1)}) + for b in s.blockList: + blockElement = ET.SubElement(sectionElement, "block") + ET.SubElement(blockElement, "length").text = str(b.length) + if b.chord is not None: + ET.SubElement(blockElement, "chord").text = b.chord.name + if b.notes is not None: + ET.SubElement(blockElement, "notes").text = b.notes + tree = ET.ElementTree(root) - tree.write(filepath) \ No newline at end of file + tree.write(filepath) diff --git a/chordsheet/pdfViewer.py b/chordsheet/pdfViewer.py new file mode 100644 index 0000000..6c63d80 --- /dev/null +++ b/chordsheet/pdfViewer.py @@ -0,0 +1,56 @@ +from PyQt5.QtWidgets import QScrollArea, QLabel, QVBoxLayout, QWidget +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap, QImage + +import fitz + +class PDFViewer(QScrollArea): + def __init__(self, parent): + super().__init__(parent) + self.scrollAreaContents = QWidget() + self.scrollAreaLayout = QVBoxLayout() + + self.setWidget(self.scrollAreaContents) + self.setWidgetResizable(True) + + self.scrollAreaContents.setLayout(self.scrollAreaLayout) + self.pixmapList = [] + + def resizeEvent(self, event): + pass + # do something about this later + + def update(self, pdf): + self.render(pdf) + self.clear() + self.show() + + def render(self, pdf): + """ + Update the preview shown by rendering a new PDF and drawing it to the scroll area. + """ + + self.pixmapList = [] + pdfView = fitz.Document(stream=pdf, filetype='pdf') + # render at 4x resolution and scale + for page in pdfView: + self.pixmapList.append(page.getPixmap(matrix=fitz.Matrix(4, 4), alpha=False)) + + def clear(self): + while self.scrollAreaLayout.count(): + item = self.scrollAreaLayout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + def show(self): + for p in self.pixmapList: + label = QLabel(parent=self.scrollAreaContents) + label.setAlignment(Qt.AlignHCenter) + qtimg = QImage(p.samples, p.width, p.height, p.stride, QImage.Format_RGB888) + # -45 because of various margins... value obtained by trial and error. + label.setPixmap(QPixmap.fromImage(qtimg).scaled(self.width()-45, self.height()*2, Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)) + self.scrollAreaLayout.addWidget(label) + + # necessary on Mojave with PyInstaller (or previous contents will be shown) + self.repaint() \ No newline at end of file diff --git a/chordsheet/render.py b/chordsheet/render.py index 3036d23..35ddcd3 100644 --- a/chordsheet/render.py +++ b/chordsheet/render.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- -from math import trunc +from math import trunc, ceil +from io import BytesIO from reportlab.pdfgen import canvas from reportlab.lib.units import mm -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.platypus import BaseDocTemplate, Spacer, Paragraph, Flowable, Frame, PageTemplate +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.platypus import BaseDocTemplate, Spacer, Paragraph, Flowable, Frame, PageTemplate, PageBreak from chordsheet.document import Block +from chordsheet.rlStylesheet import getStyleSheet def writeText(canvas, style, string, size, vpos, width, **kwargs): @@ -37,31 +39,27 @@ def writeText(canvas, style, string, size, vpos, width, **kwargs): return size*style.lineSpacing -def splitBlocks(blockList, maxWidth): - h_loc = 0 - splitBlockList = [] - for i in range(len(blockList)): - c_orig = blockList[i].chord - n_orig = blockList[i].notes - if h_loc == maxWidth: - h_loc = 0 - if h_loc+blockList[i].length > maxWidth: - lengthList = [maxWidth - h_loc] - while sum(lengthList) < blockList[i].length: - if blockList[i].length - sum(lengthList) >= maxWidth: - lengthList.append(maxWidth) - else: - lengthList.append(blockList[i].length - sum(lengthList)) - - for l in lengthList: - # create a block with the given length - splitBlockList.append(Block(l, chord=c_orig, notes=n_orig)) - - h_loc = lengthList[-1] - else: - splitBlockList.append(blockList[i]) - h_loc += blockList[i].length - return splitBlockList +class Tempo(Flowable): + """ + Flowable that draws the tempo. Necessary because Paragraph does not support the crotchet character. + """ + + def __init__(self, tempo, paraStyle): + self.tempo = tempo + self.text = "♩ = {t} bpm".format(t=self.tempo) + self.fontSize = paraStyle.fontSize + self.fontname = paraStyle.fontname + self.leading = paraStyle.leading + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self.height = self.leading + return (self.width, self.height) + + def draw(self): + canvas = self.canv + canvas.setFont(self.fontname, self.fontSize) + canvas.drawString(0, self.leading * 0.25, self.text) class GuitarChart(Flowable): @@ -77,66 +75,81 @@ class GuitarChart(Flowable): self.nStrings = 6 self.headingSize = 18 - self.spaceAfter = self.style.separatorSize * mm + self.spaceAfter = self.style.separatorSize + + def splitChordList(self, l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i:i + n] def wrap(self, availWidth, availHeight): - self.width = self.chartMargin + self.style.stringHzGap + self.style.stringHzSp * \ - (len(self.guitarChordList)) # calculate the width of the flowable - self.height = self.style.stringHeight * \ - (self.nStrings+1) + self.headingSize * \ - self.style.lineSpacing + 2*mm # and its height + self.nChords = trunc((availWidth - self.chartMargin - + self.style.stringHzGap) / self.style.stringHzSp) + # the height of one layer of chart + self.oneHeight = self.style.stringHeight * (self.nStrings+1) + # only one line needed + if len(self.guitarChordList) <= self.nChords: + self.width = self.chartMargin + self.style.stringHzGap + self.style.stringHzSp * \ + len(self.guitarChordList) # calculate the width + self.height = self.oneHeight # and its height + # multiple lines needed + else: + self.width = self.chartMargin + self.style.stringHzGap + \ + self.style.stringHzSp * self.nChords + self.height = self.oneHeight * ceil(len(self.guitarChordList) / self.nChords) + \ + (self.style.stringHeight * + trunc(len(self.guitarChordList) / self.nChords)) return (self.width, self.height) def draw(self): canvas = self.canv - title_height = writeText(canvas, self.style, "Guitar chord voicings", - self.headingSize, self.height, self.width, align="left") - chartmargin = self.chartMargin - v_origin = self.height - title_height - 2*mm - h_origin = chartmargin - self.nStrings = 6 - fontsize = 12 - - stringList = [ - [c.voicings['guitar'][-(r+1)] for c in self.guitarChordList] for r in range(self.nStrings)] - stringList.append([c.name for c in self.guitarChordList]) - - for i in range(self.nStrings+1): # i is the string line currently being drawn - writeText(canvas, self.style, ['e', 'B', 'G', 'D', 'A', 'E', 'Name'][i], fontsize, v_origin-( - i*self.style.stringHeight), self.width, hpos=h_origin, align='right') - - # j is which chord (0 is first chord, 1 is 2nd etc) - for j in range(len(stringList[-1])): - currentWidth = canvas.stringWidth(stringList[i][j]) - if j == 0: - x = self.style.stringHzGap + chartmargin - l = self.style.stringHzSp/2 - self.style.stringHzGap - \ - ((currentWidth/2)) - self.style.stringHzGap - y = v_origin-(self.style.stringHeight*i) - \ - self.style.stringHeight/2 - canvas.line(x, y, x+l, y) - else: - x = chartmargin + self.style.stringHzSp * \ - (j-0.5)+(lastWidth/2+self.style.stringHzGap) - l = self.style.stringHzSp - currentWidth / \ - 2 - lastWidth/2 - self.style.stringHzGap*2 - y = v_origin-(self.style.stringHeight*i) - \ - self.style.stringHeight/2 - canvas.line(x, y, x+l, y) - - if j == len(stringList[-1])-1: - x = chartmargin + self.style.stringHzSp * \ - (j+0.5) + currentWidth/2 + self.style.stringHzGap - l = self.style.stringHzSp/2 - currentWidth/2 - self.style.stringHzGap - y = v_origin-(self.style.stringHeight*i) - \ - self.style.stringHeight/2 - canvas.line(x, y, x+l, y) - - writeText(canvas, self.style, stringList[i][j], fontsize, v_origin-( - i*self.style.stringHeight), self.width, hpos=chartmargin+self.style.stringHzSp*(j+0.5)) - - lastWidth = currentWidth + + for count, gcl in enumerate(self.splitChordList(self.guitarChordList, self.nChords)): + v_origin = self.height - count * (self.oneHeight + self.style.stringHeight) + + self.nStrings = 6 + fontsize = 12 + + stringList = [ + [c.voicings['guitar'][-(r+1)] for c in gcl] for r in range(self.nStrings)] + stringList.append([c.name for c in gcl]) + + for i in range(self.nStrings+1): # i is the string line currently being drawn + writeText(canvas, self.style, ['e', 'B', 'G', 'D', 'A', 'E', 'Name'][i], fontsize, v_origin-( + i*self.style.stringHeight), self.width, hpos=chartmargin, align='right') + + # j is which chord (0 is first chord, 1 is 2nd etc) + for j in range(len(stringList[-1])): + currentWidth = canvas.stringWidth(stringList[i][j]) + if j == 0: + x = self.style.stringHzGap + chartmargin + l = self.style.stringHzSp/2 - self.style.stringHzGap - \ + ((currentWidth/2)) - self.style.stringHzGap + y = v_origin-(self.style.stringHeight*i) - \ + self.style.stringHeight/2 + canvas.line(x, y, x+l, y) + else: + x = chartmargin + self.style.stringHzSp * \ + (j-0.5)+(lastWidth/2+self.style.stringHzGap) + l = self.style.stringHzSp - currentWidth / \ + 2 - lastWidth/2 - self.style.stringHzGap*2 + y = v_origin-(self.style.stringHeight*i) - \ + self.style.stringHeight/2 + canvas.line(x, y, x+l, y) + + if j == len(stringList[-1])-1: + x = chartmargin + self.style.stringHzSp * \ + (j+0.5) + currentWidth/2 + self.style.stringHzGap + l = self.style.stringHzSp/2 - currentWidth/2 - self.style.stringHzGap + y = v_origin-(self.style.stringHeight*i) - \ + self.style.stringHeight/2 + canvas.line(x, y, x+l, y) + + writeText(canvas, self.style, stringList[i][j], fontsize, v_origin-( + i*self.style.stringHeight), self.width, hpos=chartmargin+self.style.stringHzSp*(j+0.5)) + + lastWidth = currentWidth class ChordProgression(Flowable): @@ -144,33 +157,89 @@ class ChordProgression(Flowable): Flowable that draws a chord progression made up of blocks. """ - def __init__(self, style, blockList, timeSignature): + def __init__(self, style, heading, blockList, timeSignature): self.style = style + self.heading = heading # the title of the section self.blockList = blockList self.timeSignature = timeSignature self.headingSize = 18 - self.spaceAfter = self.style.separatorSize * mm + self.spaceAfter = self.style.separatorSize + + def wrapBlocks(self, blockList, maxWidth): + """ + Splits any blocks that won't fit in the remaining space on the line. + """ + h_loc = 0 + splitBlockList = [] + for i in range(len(blockList)): + c_orig = blockList[i].chord + n_orig = blockList[i].notes + if h_loc == maxWidth: + h_loc = 0 + if h_loc+blockList[i].length > maxWidth: + lengthList = [maxWidth - h_loc] + while sum(lengthList) < blockList[i].length: + if blockList[i].length - sum(lengthList) >= maxWidth: + lengthList.append(maxWidth) + # print(lengthList) + else: + lengthList.append( + blockList[i].length - sum(lengthList)) + # print(lengthList) + + for l in lengthList: + # create a block with the given length + splitBlockList.append(Block(l, chord=c_orig, notes=n_orig)) + + h_loc = lengthList[-1] + else: + splitBlockList.append(blockList[i]) + h_loc += blockList[i].length + return splitBlockList + + def splitBlockList(self, blockList, length): + """ + Splits a blockList into two lists, one of the given length (in beats) and one for the rest. Also wraps the blocks to + given length in case the split would fall in the middle of one. + """ + secondPart = self.wrapBlocks(blockList, length) + firstPart = [] + currentBeat = 0 + while currentBeat != length: + block = secondPart.pop(0) + firstPart.append(block) + currentBeat += block.length + + return firstPart, secondPart def wrap(self, availWidth, availHeight): self.widthInBeats = 2 * self.timeSignature * \ trunc((availWidth/(self.style.unitWidth*self.style.unit)) / (2*self.timeSignature)) # width of each line, in beats self.width = self.widthInBeats * self.style.unitWidth * self.style.unit - self.height = self.headingSize * self.style.lineSpacing + 2 * mm + self.style.beatsHeight + \ - self.style.unitHeight * \ + self.height = self.style.beatsHeight + self.style.unitHeight * \ sum([b.length for b in self.blockList]) / self.widthInBeats return(self.width, self.height) + def split(self, availWidth, availHeight): + if availHeight >= self.height: + return [self] + else: + vUnits = trunc( + (availHeight - self.style.beatsHeight) / self.style.unitHeight) + firstPart, secondPart = self.splitBlockList( + self.blockList, vUnits * self.widthInBeats) + + return [ChordProgression(self.style, self.heading, firstPart, self.timeSignature), + PageBreak(), + ChordProgression(self.style, self.heading, secondPart, self.timeSignature)] + def draw(self): canvas = self.canv unitWidth = self.style.unitWidth*self.style.unit - title_height = writeText(canvas, self.style, "Chord progression", - self.headingSize, self.height, self.width, align="left") - - v_origin = self.height - self.style.beatsHeight - title_height - 2*mm - h_origin = 0 + v_origin = self.height - self.style.beatsHeight h_loc = 0 v_loc = 0 @@ -190,7 +259,7 @@ class ChordProgression(Flowable): writeText(canvas, self.style, str((u % self.timeSignature)+1), self.style.beatsFontSize, v_origin+self.style.beatsHeight, self.width, hpos=x+unitWidth/2) - parsedBlockList = splitBlocks(self.blockList, maxWidth) + parsedBlockList = self.wrapBlocks(self.blockList, maxWidth) for b in parsedBlockList: if h_loc == maxWidth: @@ -200,12 +269,12 @@ class ChordProgression(Flowable): b.length*unitWidth, self.style.unitHeight) if b.notes is not None: writeText(canvas, self.style, b.notes, self.style.notesFontSize, v_origin-((v_loc+1)*self.style.unitHeight)+( - 1.3*self.style.notesFontSize), self.width, hpos=h_origin+((h_loc+b.length/2)*unitWidth)) + 1.3*self.style.notesFontSize), self.width, hpos=((h_loc+b.length/2)*unitWidth)) v_offset = ((v_loc*self.style.unitHeight) + self.style.unitHeight/2)-self.style.chordNameFontSize/2 if b.chord is not None: writeText(canvas, self.style, b.chord.name, self.style.chordNameFontSize, - v_origin-v_offset, self.width, hpos=h_origin+((h_loc+b.length/2)*unitWidth)) + v_origin-v_offset, self.width, hpos=((h_loc+b.length/2)*unitWidth)) h_loc += b.length @@ -218,73 +287,59 @@ def guitarChartCheck(cL): return chordsPresent -class TitleBlock(Flowable): - """ - Flowable that draws the title and other text at the top of the document. - """ - - def __init__(self, style, document): +class Renderer: + def __init__(self, document, style): + self.document = document self.style = style - self.lS = style.lineSpacing - - self.title = document.title - self.subtitle = document.subtitle - self.composer = document.composer - self.arranger = document.arranger - self.tempo = document.tempo - - self.spaceAfter = self.style.separatorSize * mm - def wrap(self, availWidth, availHeight): - self.width = availWidth - self.height = sum([self.style.titleFontSize * self.lS if self.title else 0, - self.style.subtitleFontSize * self.lS if self.subtitle else 0, - self.style.creditsFontSize * self.lS if self.composer else 0, - self.style.titleFontSize * self.lS if self.arranger else 0, - self.style.tempoFontSize * self.lS if self.tempo else 0]) - return(self.width, self.height) - - def draw(self): - canvas = self.canv - curPos = self.height + def savePDF(self, pathToPDF): + template = PageTemplate(id='AllPages', frames=[Frame(self.style.leftMargin*mm, self.style.bottomMargin*mm, + self.style.pageSize[0] - self.style.leftMargin*mm - self.style.rightMargin*mm, + self.style.pageSize[1] - self.style.topMargin*mm - self.style.bottomMargin*mm, + leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0)]) - if self.title: - curPos -= writeText(canvas, self.style, - self.title, 24, curPos, self.width) + rlDocList = [] + rlDoc = BaseDocTemplate( + pathToPDF, pagesize=self.style.pageSize, pageTemplates=[template]) - if self.subtitle: - curPos -= writeText(canvas, self.style, - self.subtitle, 18, curPos, self.width) + styles = getStyleSheet(self.style) - if self.composer: - curPos -= writeText(canvas, self.style, - "Composer: {c}".format(c=self.composer), 12, curPos, self.width) + if self.document.title: + rlDocList.append(Paragraph(self.document.title, styles['Title'])) - if self.arranger: - curPos -= writeText(canvas, self.style, - "Arranger: {a}".format(a=self.arranger), 12, curPos, self.width) + if self.document.subtitle: + rlDocList.append( + Paragraph(self.document.subtitle, styles['Subtitle'])) - if self.tempo: - curPos -= writeText(canvas, self.style, "♩ = {t} bpm".format( - t=self.tempo), 12, curPos, self.width, align="left") + if self.document.composer: + rlDocList.append(Paragraph("Composer: {c}".format( + c=self.document.composer), styles['Credits'])) + if self.document.arranger: + rlDocList.append(Paragraph("Arranger: {a}".format( + a=self.document.arranger), styles['Credits'])) -def savePDF(document, style, pathToPDF): - template = PageTemplate(id='AllPages', frames=[Frame(style.leftMargin*mm, style.topMargin*mm, style.pageSize[0] - style.leftMargin*mm*2, style.pageSize[1] - style.topMargin*mm*2, - leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0)]) + if self.document.tempo: + rlDocList.append(Tempo(self.document.tempo, styles['Tempo'])) - rlDocList = [] - rlDoc = BaseDocTemplate( - pathToPDF, pagesize=style.pageSize, pageTemplates=[template]) + if self.document.title or self.document.subtitle or self.document.composer or self.document.arranger or self.document.tempo: + rlDocList.append(Spacer(0, self.style.separatorSize)) - if document.title: - rlDocList.append(TitleBlock(style, document)) + if guitarChartCheck(self.document.chordList): + rlDocList.extend([ + Paragraph('Guitar chord voicings', styles['Heading']), + GuitarChart(self.style, self.document.chordList)]) - if guitarChartCheck(document.chordList): - rlDocList.append(GuitarChart(style, document.chordList)) + for s in self.document.sectionList: + rlDocList.append(Paragraph(s.name, styles['Heading'])) + # only draw the chord progression if there are blocks + if s.blockList: + rlDocList.append(ChordProgression( + self.style, s.name, s.blockList, self.document.timeSignature)) - if document.blockList: - rlDocList.append(ChordProgression( - style, document.blockList, document.timeSignature)) + rlDoc.build(rlDocList) - rlDoc.build(rlDocList) + def stream(self): + virtualFile = BytesIO() + self.savePDF(virtualFile) + return virtualFile diff --git a/chordsheet/rlStylesheet.py b/chordsheet/rlStylesheet.py new file mode 100644 index 0000000..f561216 --- /dev/null +++ b/chordsheet/rlStylesheet.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +from reportlab.lib.styles import StyleSheet1, ParagraphStyle +from reportlab.lib.enums import * +from reportlab.lib.units import mm +from reportlab.lib.colors import black + +def getStyleSheet(csStyle): + """Returns a stylesheet object""" + stylesheet = StyleSheet1() + + stylesheet.add(ParagraphStyle(name='Master', + fontname=csStyle.font)) + + stylesheet.add(ParagraphStyle(name='Title', + leading=csStyle.lineSpacing*csStyle.titleFontSize, + fontSize=csStyle.titleFontSize, + alignment=TA_CENTER, + parent=stylesheet['Master']) + ) + + stylesheet.add(ParagraphStyle(name='Subtitle', + leading=csStyle.lineSpacing*csStyle.subtitleFontSize, + fontSize=csStyle.subtitleFontSize, + alignment=TA_CENTER, + parent=stylesheet['Master']) + ) + stylesheet.add(ParagraphStyle(name='Credits', + leading=csStyle.lineSpacing*csStyle.creditsFontSize, + fontSize=csStyle.creditsFontSize, + alignment=TA_CENTER, + parent=stylesheet['Master']) + ) + + stylesheet.add(ParagraphStyle(name='Tempo', + leading=csStyle.lineSpacing*csStyle.tempoFontSize, + fontSize=csStyle.tempoFontSize, + alignment=TA_LEFT, + parent=stylesheet['Master']) + ) + + stylesheet.add(ParagraphStyle(name='Heading', + leading=csStyle.lineSpacing*csStyle.headingFontSize, + fontSize=csStyle.headingFontSize, + alignment=TA_LEFT, + parent=stylesheet['Master'], + spaceAfter=2*mm) + ) + + # stylesheet.add(ParagraphStyle(name='Heading3', + # parent=stylesheet['Normal'], + # fontName = csStyle.font, + # fontSize=12, + # leading=14, + # spaceBefore=12, + # spaceAfter=6), + # alias='h3') + + # stylesheet.add(ParagraphStyle(name='Heading4', + # parent=stylesheet['Normal'], + # fontName = csStyle.font, + # fontSize=10, + # leading=12, + # spaceBefore=10, + # spaceAfter=4), + # alias='h4') + + # stylesheet.add(ParagraphStyle(name='Heading5', + # parent=stylesheet['Normal'], + # fontName = csStyle.font, + # fontSize=9, + # leading=10.8, + # spaceBefore=8, + # spaceAfter=4), + # alias='h5') + + # stylesheet.add(ParagraphStyle(name='Heading6', + # parent=stylesheet['Normal'], + # fontName = csStyle.font, + # fontSize=7, + # leading=8.4, + # spaceBefore=6, + # spaceAfter=2), + # alias='h6') + + # stylesheet.add(ParagraphStyle(name='Bullet', + # parent=stylesheet['Normal'], + # firstLineIndent=0, + # spaceBefore=3), + # alias='bu') + + # stylesheet.add(ParagraphStyle(name='Definition', + # parent=stylesheet['Normal'], + # firstLineIndent=0, + # leftIndent=36, + # bulletIndent=0, + # spaceBefore=6, + # bulletFontName=csStyle.font), + # alias='df') + + # stylesheet.add(ParagraphStyle(name='Code', + # parent=stylesheet['Normal'], + # fontName='Courier', + # fontSize=8, + # leading=8.8, + # firstLineIndent=0, + # leftIndent=36, + # hyphenationLang='')) + + return stylesheet \ No newline at end of file diff --git a/chordsheet/tableView.py b/chordsheet/tableView.py index ab6170e..4cd5e17 100644 --- a/chordsheet/tableView.py +++ b/chordsheet/tableView.py @@ -81,6 +81,30 @@ class ChordTableView(MTableView): self.model.appendRow(rowList) +class SectionTableView(MTableView): + """ + Subclass MTableView to add properties just for the section table. + """ + + def __init__(self, parent): + super().__init__(parent) + + self.model.setHorizontalHeaderLabels(['Name']) + + def populate(self, sList): + """ + Fill the table from a list of Section objects. + """ + self.model.removeRows(0, self.model.rowCount()) + for s in sList: + rowList = [QtGui.QStandardItem(s.name)] + for item in rowList: + item.setEditable(False) + item.setDropEnabled(False) + + self.model.appendRow(rowList) + + class BlockTableView(MTableView): """ Subclass MTableView to add properties just for the block table. diff --git a/examples/ah.xml b/examples/ah.xml index 3415169..0d11a0c 100644 --- a/examples/ah.xml +++ b/examples/ah.xml @@ -27,7 +27,7 @@ x69676 - +
9 Gm9 @@ -73,5 +73,5 @@ D7#5#9 over Ab - +
\ No newline at end of file diff --git a/examples/ahlong.xml b/examples/ahlong.xml new file mode 100644 index 0000000..df0a2c5 --- /dev/null +++ b/examples/ahlong.xml @@ -0,0 +1,309 @@ + + "African Heritage" + A corroboration + Ivan Holmes + Ivan Holmes and Joe Buckley + 120 + 6 + + + Gm9 + x,10,8,10,10,x + + + Abm9 + x,11,9,11,11,x + + + Cm9 + x,x,8,8,8,10 + + + D7#5#9 + x58565 + + + Eb7#5#9 + x69676 + + +
+ + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 9 + Cm9 + + + 3 + D7#5#9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 6 + Cm9 + + + 3 + D7#5#9 + + + 3 + D7#5#9 + over Ab + +
+
+ + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 12 + Cm9 + +
+
+ + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 12 + Cm9 + + + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 9 + Cm9 + + + 3 + D7#5#9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 6 + Cm9 + + + 3 + D7#5#9 + + + 3 + D7#5#9 + over Ab + + + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 9 + Cm9 + + + 3 + D7#5#9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 6 + Cm9 + + + 3 + D7#5#9 + + + 3 + D7#5#9 + over Ab + + + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 9 + Cm9 + + + 3 + D7#5#9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 6 + Cm9 + + + 3 + D7#5#9 + + + 3 + D7#5#9 + over Ab + + + 9 + Gm9 + + + 3 + Abm9 + + + 12 + Gm9 + + + 9 + Cm9 + + + 3 + D7#5#9 + + + 12 + Gm9 + + + 6 + Eb7#5#9 + + + 6 + D7#5#9 + + + 6 + Cm9 + + + 3 + D7#5#9 + + + 3 + D7#5#9 + over Ab + +
+
\ No newline at end of file diff --git a/examples/angela.xml b/examples/angela.xml new file mode 100644 index 0000000..b46b457 --- /dev/null +++ b/examples/angela.xml @@ -0,0 +1 @@ +AngelaTheme from 'Taxi'MaxBob James4E♭maj7x,x,1,3,3,3A♭ma7x,x,1,1,1,3Gm73,x,3,3,3,xB♭/Dx,x,0,3,3,1A♭maj7/Cx,3,1,1,1,3B♭x,1,3,3,3,1A♭4,6,6,5,4,4E♭/Gx,x,5,3,4,3Fmx,x,3,1,1,1A♭/B♭6,x,6,5,4,4E♭x,6,5,3,4,3E♭7x,x,1,3,2,3A♭maj94,x,5,3,4,3Fm71,x,1,1,1,x
2.0E♭maj72.0A♭ma72.0Gm72.0B♭/D2.0A♭maj7/C2.0B♭1.0A♭1.0E♭/G1.0Fm1.0A♭/B♭2.0E♭2.0E♭72.0A♭maj92.0E♭/G2.0Fm72.0B♭1.0A♭1.0E♭/G1.0Fm1.0A♭/B♭
\ No newline at end of file diff --git a/examples/example.xml b/examples/example.xml index b0f503a..70c62a9 100644 --- a/examples/example.xml +++ b/examples/example.xml @@ -1 +1 @@ -Example SongIvan Holmes4Cx,3,2,0,1,0F1,3,3,2,1,1G3,2,0,0,0,3C/G3,3,2,0,1,0Dmx,x,0,2,3,116CIntro, strum lightly4C4F8G4C4F8G4C4Dm4C/G4G4C4Contemplation time8CCrescendo until end \ No newline at end of file +Example SongIvan Holmes4Cx,3,2,0,1,0F1,3,3,2,1,1G3,2,0,0,0,3C/G3,3,2,0,1,0Dmx,x,0,2,3,1
16CIntro, strum lightly4C4F8G4C4F8G4C4Dm4C/G4G4C4Contemplation time8CCrescendo until end
\ No newline at end of file diff --git a/examples/kissoflife.xml b/examples/kissoflife.xml new file mode 100644 index 0000000..a03e96e --- /dev/null +++ b/examples/kissoflife.xml @@ -0,0 +1 @@ +Kiss of LifeSadeIvan HolmesSade Adu, Paul S. Denman, Andrew Hale, Stuart Matthewman4AM9F♯m11DM7C♯m7Bm7
8.0AM98.0F♯m111.5DM72.0C♯m74.5Bm78.0F♯m11
3.5Bm74.5F♯m113.5Bm74.5F♯m11
\ No newline at end of file diff --git a/examples/test.xml b/examples/test.xml index ca83cb8..e6d8e11 100644 --- a/examples/test.xml +++ b/examples/test.xml @@ -1 +1 @@ -CompositionA. Person4Bx,x,2,3,4,1E0,2,2,1,0,0Cm9x,x,8,8,8,10D7♭5♯94BThese are notes.4E12Cm96D7♭5♯96For quiet contemplation.46D7♭5♯9A very long block to test wrapping! \ No newline at end of file +CompositionA. Person4Bx,x,2,3,4,1E0,2,2,1,0,0Cm9x,x,8,8,8,10D7♭5♯9
4BThese are notes.4E12Cm96D7♭5♯96For quiet contemplation.46D7♭5♯9A very long block to test wrapping!
\ No newline at end of file diff --git a/gui.py b/gui.py index f8ab90e..c3d83d0 100755 --- a/gui.py +++ b/gui.py @@ -11,6 +11,7 @@ import fitz import io import subprocess import os +import time from copy import copy from PyQt5.QtWidgets import QApplication, QAction, QLabel, QDialogButtonBox, QDialog, QFileDialog, QMessageBox, QPushButton, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QTableWidgetItem, QTabWidget, QComboBox, QWidget, QScrollArea, QMainWindow, QShortcut @@ -18,14 +19,16 @@ from PyQt5.QtCore import QFile, QObject, Qt, pyqtSlot, QSettings from PyQt5.QtGui import QPixmap, QImage, QKeySequence from PyQt5 import uic from chordsheet.tableView import ChordTableView, BlockTableView +from chordsheet.comboBox import MComboBox +from chordsheet.pdfViewer import PDFViewer from reportlab.lib.units import mm, cm, inch, pica from reportlab.lib.pagesizes import A4, A5, LETTER, LEGAL from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont -from chordsheet.document import Document, Style, Chord, Block -from chordsheet.render import savePDF +from chordsheet.document import Document, Style, Chord, Block, Section +from chordsheet.render import Renderer from chordsheet.parsers import parseFingering, parseName import _version @@ -72,6 +75,7 @@ class DocumentWindow(QMainWindow): self.doc = doc self.style = style + self.renderer = Renderer(self.doc, self.style) self.lastDoc = copy(self.doc) self.currentFilePath = filename @@ -79,6 +83,8 @@ class DocumentWindow(QMainWindow): self.UIFileLoader(str(os.path.join(scriptDir, 'ui', 'mainwindow.ui'))) self.UIInitStyle() self.updateChordDict() + self.updateSectionDict() + self.currentSection = None self.setCentralWidget(self.window.centralWidget) self.setMenuBar(self.window.menuBar) @@ -146,17 +152,32 @@ class DocumentWindow(QMainWindow): self.window.generateButton.clicked.connect(self.generateAction) + # update whole document when any tab is selected + self.window.tabWidget.tabBarClicked.connect(self.tabBarUpdateAction) + self.window.guitarVoicingButton.clicked.connect( self.guitarVoicingAction) self.window.addChordButton.clicked.connect(self.addChordAction) self.window.removeChordButton.clicked.connect(self.removeChordAction) self.window.updateChordButton.clicked.connect(self.updateChordAction) + # connecting clicked only works for this combo box because it's my own modified version (MComboBox) + self.window.blockSectionComboBox.clicked.connect( + self.blockSectionClickedAction) + self.window.blockSectionComboBox.currentIndexChanged.connect( + self.blockSectionChangedAction) self.window.addBlockButton.clicked.connect(self.addBlockAction) self.window.removeBlockButton.clicked.connect(self.removeBlockAction) self.window.updateBlockButton.clicked.connect(self.updateBlockAction) + self.window.addSectionButton.clicked.connect(self.addSectionAction) + self.window.removeSectionButton.clicked.connect( + self.removeSectionAction) + self.window.updateSectionButton.clicked.connect( + self.updateSectionAction) + self.window.chordTableView.clicked.connect(self.chordClickedAction) + self.window.sectionTableView.clicked.connect(self.sectionClickedAction) self.window.blockTableView.clicked.connect(self.blockClickedAction) def UIInitDocument(self): @@ -167,13 +188,20 @@ class DocumentWindow(QMainWindow): # set all fields to appropriate values from document self.window.titleLineEdit.setText(self.doc.title) + self.window.subtitleLineEdit.setText(self.doc.subtitle) self.window.composerLineEdit.setText(self.doc.composer) self.window.arrangerLineEdit.setText(self.doc.arranger) self.window.timeSignatureSpinBox.setValue(self.doc.timeSignature) self.window.tempoLineEdit.setText(self.doc.tempo) self.window.chordTableView.populate(self.doc.chordList) - self.window.blockTableView.populate(self.doc.blockList) + self.window.sectionTableView.populate(self.doc.sectionList) + # populate the block table with the first section, account for a document with no sections + self.currentSection = self.doc.sectionList[0] if len( + self.doc.sectionList) else None + self.window.blockTableView.populate( + self.currentSection.blockList if self.currentSection else []) + self.updateSectionDict() self.updateChordDict() def UIInitStyle(self): @@ -191,13 +219,19 @@ class DocumentWindow(QMainWindow): self.window.lineSpacingDoubleSpinBox.setValue(self.style.lineSpacing) self.window.leftMarginLineEdit.setText(str(self.style.leftMargin)) + self.window.rightMarginLineEdit.setText(str(self.style.rightMargin)) self.window.topMarginLineEdit.setText(str(self.style.topMargin)) + self.window.bottomMarginLineEdit.setText(str(self.style.bottomMargin)) + self.window.fontComboBox.setDisabled(True) self.window.includedFontCheckBox.setChecked(True) self.window.beatWidthLineEdit.setText(str(self.style.unitWidth)) + def tabBarUpdateAction(self, index): + self.updateDocument() + def pageSizeAction(self, index): self.pageSizeSelected = self.window.pageSizeComboBox.itemText(index) @@ -217,6 +251,29 @@ class DocumentWindow(QMainWindow): self.window.guitarVoicingLineEdit.setText( self.window.chordTableView.model.item(index.row(), 1).text()) + def sectionClickedAction(self, index): + # set the controls to the values from the selected section + self.window.sectionNameLineEdit.setText( + self.window.sectionTableView.model.item(index.row(), 0).text()) + # also set the combo box on the block page to make it flow well + curSecName = self.window.sectionTableView.model.item( + index.row(), 0).text() + if curSecName: + self.window.blockSectionComboBox.setCurrentText( + curSecName) + + def blockSectionClickedAction(self, text): + if text: + self.updateBlocks(self.sectionDict[text]) + + def blockSectionChangedAction(self, index): + sName = self.window.blockSectionComboBox.currentText() + if sName: + self.currentSection = self.sectionDict[sName] + self.window.blockTableView.populate(self.currentSection.blockList) + else: + self.currentSection = None + def blockClickedAction(self, index): # set the controls to the values from the selected block bChord = self.window.blockTableView.model.item(index.row(), 0).text() @@ -245,6 +302,8 @@ class DocumentWindow(QMainWindow): self.lastDoc = copy(self.doc) #  reset file path (this document hasn't been saved yet) self.currentFilePath = None + # new renderer + self.renderer = Renderer(self.doc, self.style) self.UIInitDocument() self.updatePreview() @@ -297,7 +356,7 @@ class DocumentWindow(QMainWindow): filePath = QFileDialog.getSaveFileName(self.window.tabWidget, 'Save file', self.getPath( "lastExportPath"), "PDF files (*.pdf)")[0] if filePath: - savePDF(d, s, filePath) + self.renderer.savePDF(filePath) self.setPath("lastExportPath", filePath) def menuFilePrintAction(self): @@ -381,6 +440,18 @@ class DocumentWindow(QMainWindow): self.window.chordNameLineEdit.repaint() self.window.guitarVoicingLineEdit.repaint() + def clearSectionLineEdits(self): + self.window.sectionNameLineEdit.clear() + # necessary on Mojave with PyInstaller (or previous contents will be shown) + self.window.sectionNameLineEdit.repaint() + + def clearBlockLineEdits(self): + self.window.blockLengthLineEdit.clear() + self.window.blockNotesLineEdit.clear() + # necessary on Mojave with PyInstaller (or previous contents will be shown) + self.window.blockLengthLineEdit.repaint() + self.window.blockNotesLineEdit.repaint() + def updateChordDict(self): """ Updates the dictionary used to generate the Chord menu (on the block tab) @@ -390,6 +461,15 @@ class DocumentWindow(QMainWindow): self.window.blockChordComboBox.clear() self.window.blockChordComboBox.addItems(list(self.chordDict.keys())) + def updateSectionDict(self): + """ + Updates the dictionary used to generate the Section menu (on the block tab) + """ + self.sectionDict = {s.name: s for s in self.doc.sectionList} + self.window.blockSectionComboBox.clear() + self.window.blockSectionComboBox.addItems( + list(self.sectionDict.keys())) + def removeChordAction(self): if self.window.chordTableView.selectionModel().hasSelection(): #  check for selection self.updateChords() @@ -418,7 +498,7 @@ class DocumentWindow(QMainWindow): else: success = True #  chord successfully parsed else: - NameWarningMessageBox().exec() # Chord has no name, warn user + ChordNameWarningMessageBox().exec() # Chord has no name, warn user if success == True: # if chord was parsed properly self.window.chordTableView.populate(self.doc.chordList) @@ -430,9 +510,10 @@ class DocumentWindow(QMainWindow): if self.window.chordTableView.selectionModel().hasSelection(): #  check for selection self.updateChords() row = self.window.chordTableView.selectionModel().currentIndex().row() + oldName = self.window.chordTableView.model.item(row, 0).text() cName = parseName(self.window.chordNameLineEdit.text()) if cName: - self.doc.chordList[row] = Chord(cName) + self.doc.chordList[row].name = cName if self.window.guitarVoicingLineEdit.text(): try: self.doc.chordList[row].voicings['guitar'] = parseFingering( @@ -443,44 +524,83 @@ class DocumentWindow(QMainWindow): else: success = True else: - NameWarningMessageBox().exec() + ChordNameWarningMessageBox().exec() if success == True: + self.updateChordDict() self.window.chordTableView.populate(self.doc.chordList) + # update the names of chords in all blocklists in case they've already been used + for s in self.doc.sectionList: + for b in s.blockList: + if b.chord: + if b.chord.name == oldName: + b.chord.name = cName + self.window.blockTableView.populate(self.currentSection.blockList) self.clearChordLineEdits() - self.updateChordDict() - def clearBlockLineEdits(self): - self.window.blockLengthLineEdit.clear() - self.window.blockNotesLineEdit.clear() - # necessary on Mojave with PyInstaller (or previous contents will be shown) - self.window.blockLengthLineEdit.repaint() - self.window.blockNotesLineEdit.repaint() + def removeSectionAction(self): + if self.window.sectionTableView.selectionModel().hasSelection(): #  check for selection + self.updateSections() + + row = self.window.sectionTableView.selectionModel().currentIndex().row() + self.doc.sectionList.pop(row) + + self.window.sectionTableView.populate(self.doc.sectionList) + self.clearSectionLineEdits() + self.updateSectionDict() + + def addSectionAction(self): + self.updateSections() + + sName = self.window.sectionNameLineEdit.text() + if sName and sName not in [s.name for s in self.doc.sectionList]: + self.doc.sectionList.append(Section(name=sName)) + self.window.sectionTableView.populate(self.doc.sectionList) + self.clearSectionLineEdits() + self.updateSectionDict() + else: + # Section has no name or non unique, warn user + SectionNameWarningMessageBox().exec() + + def updateSectionAction(self): + if self.window.sectionTableView.selectionModel().hasSelection(): #  check for selection + self.updateSections() + row = self.window.sectionTableView.selectionModel().currentIndex().row() + + sName = self.window.sectionNameLineEdit.text() + if sName and sName not in [s.name for s in self.doc.sectionList]: + self.doc.sectionList[row].name = sName + self.window.sectionTableView.populate(self.doc.sectionList) + self.clearSectionLineEdits() + self.updateSectionDict() + else: + # Section has no name or non unique, warn user + SectionNameWarningMessageBox().exec() def removeBlockAction(self): if self.window.blockTableView.selectionModel().hasSelection(): #  check for selection - self.updateBlocks() + self.updateBlocks(self.currentSection) row = self.window.blockTableView.selectionModel().currentIndex().row() - self.doc.blockList.pop(row) + self.currentSection.blockList.pop(row) - self.window.blockTableView.populate(self.doc.blockList) + self.window.blockTableView.populate(self.currentSection.blockList) def addBlockAction(self): - self.updateBlocks() + self.updateBlocks(self.currentSection) try: - #  can the value entered for block length be cast as an integer - bLength = int(self.window.blockLengthLineEdit.text()) + #  can the value entered for block length be cast as a float + bLength = float(self.window.blockLengthLineEdit.text()) except Exception: bLength = False if bLength: # create the block - self.doc.blockList.append(Block(bLength, - chord=self.chordDict[self.window.blockChordComboBox.currentText( - )], - notes=(self.window.blockNotesLineEdit.text() if not "" else None))) - self.window.blockTableView.populate(self.doc.blockList) + self.currentSection.blockList.append(Block(bLength, + chord=self.chordDict[self.window.blockChordComboBox.currentText( + )], + notes=(self.window.blockNotesLineEdit.text() if not "" else None))) + self.window.blockTableView.populate(self.currentSection.blockList) self.clearBlockLineEdits() else: # show warning that length was not entered or in wrong format @@ -488,20 +608,22 @@ class DocumentWindow(QMainWindow): def updateBlockAction(self): if self.window.blockTableView.selectionModel().hasSelection(): #  check for selection - self.updateBlocks() + self.updateBlocks(self.currentSection) try: - bLength = int(self.window.blockLengthLineEdit.text()) + #  can the value entered for block length be cast as a float + bLength = float(self.window.blockLengthLineEdit.text()) except Exception: bLength = False row = self.window.blockTableView.selectionModel().currentIndex().row() if bLength: - self.doc.blockList[row] = (Block(bLength, - chord=self.chordDict[self.window.blockChordComboBox.currentText( - )], - notes=(self.window.blockNotesLineEdit.text() if not "" else None))) - self.window.blockTableView.populate(self.doc.blockList) + self.currentSection.blockList[row] = (Block(bLength, + chord=self.chordDict[self.window.blockChordComboBox.currentText( + )], + notes=(self.window.blockNotesLineEdit.text() if not "" else None))) + self.window.blockTableView.populate( + self.currentSection.blockList) self.clearBlockLineEdits() else: LengthWarningMessageBox().exec() @@ -515,25 +637,13 @@ class DocumentWindow(QMainWindow): Update the preview shown by rendering a new PDF and drawing it to the scroll area. """ try: - self.currentPreview = io.BytesIO() - savePDF(self.doc, self.style, self.currentPreview) - - pdfView = fitz.Document(stream=self.currentPreview, filetype='pdf') - # render at 4x resolution and scale - pix = pdfView[0].getPixmap(matrix=fitz.Matrix(4, 4), alpha=False) - - fmt = QImage.Format_RGB888 - qtimg = QImage(pix.samples, pix.width, pix.height, pix.stride, fmt) - - self.window.imageLabel.setPixmap(QPixmap.fromImage(qtimg).scaled(self.window.scrollArea.width( - )-30, self.window.scrollArea.height()-30, Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)) - # -30 because the scrollarea has a margin of 12 each side (extra for safety) - # necessary on Mojave with PyInstaller (or previous contents will be shown) - self.window.imageLabel.repaint() + self.currentPreview = self.renderer.stream() except Exception: QMessageBox.warning(self, "Preview failed", "Could not update the preview.", buttons=QMessageBox.Ok, defaultButton=QMessageBox.Ok) + self.window.pdfArea.update(self.currentPreview) + def updateTitleBar(self): """ Update the application's title bar to reflect the current document. @@ -558,13 +668,39 @@ class DocumentWindow(QMainWindow): self.doc.chordList = chordTableList - def updateBlocks(self): + def matchSection(self, nameToMatch): + """ + Given the name of a section, this function checks if it is already present in the document. + If it is, it's returned. If not, a new section with the given name is returned. + """ + section = None + for s in self.doc.sectionList: + if s.name == nameToMatch: + section = s + break + if section is None: + section = Section(name=nameToMatch) + return section + + def updateSections(self): + """ + Update the section list by reading the table + """ + sectionTableList = [] + for i in range(self.window.sectionTableView.model.rowCount()): + sectionTableList.append(self.matchSection( + self.window.sectionTableView.model.item(i, 0).text())) + + self.doc.sectionList = sectionTableList + + def updateBlocks(self, section): """ Update the block list by reading the table. """ + blockTableList = [] for i in range(self.window.blockTableView.model.rowCount()): - blockLength = int( + blockLength = float( self.window.blockTableView.model.item(i, 1).text()) blockChord = self.chordDict[(self.window.blockTableView.model.item( i, 0).text() if self.window.blockTableView.model.item(i, 0).text() else "None")] @@ -573,7 +709,7 @@ class DocumentWindow(QMainWindow): blockTableList.append( Block(blockLength, chord=blockChord, notes=blockNotes)) - self.doc.blockList = blockTableList + section.blockList = blockTableList def updateDocument(self): """ @@ -596,8 +732,12 @@ class DocumentWindow(QMainWindow): self.style.unit = unitDict[self.unitSelected] self.style.leftMargin = float(self.window.leftMarginLineEdit.text( )) if self.window.leftMarginLineEdit.text() else self.style.leftMargin + self.style.rightMargin = float(self.window.rightMarginLineEdit.text( + )) if self.window.rightMarginLineEdit.text() else self.style.rightMargin self.style.topMargin = float(self.window.topMarginLineEdit.text( )) if self.window.topMarginLineEdit.text() else self.style.topMargin + self.style.bottomMargin = float(self.window.bottomMarginLineEdit.text( + )) if self.window.bottomMarginLineEdit.text() else self.style.bottomMargin self.style.lineSpacing = float(self.window.lineSpacingDoubleSpinBox.value( )) if self.window.lineSpacingDoubleSpinBox.value() else self.style.lineSpacing @@ -612,8 +752,11 @@ class DocumentWindow(QMainWindow): QMessageBox.warning(self, "Out of range", "Beat width is out of range. It can be a maximum of {}.".format( maxBeatWidth), buttons=QMessageBox.Ok, defaultButton=QMessageBox.Ok) + # update chords, sections, blocks self.updateChords() - self.updateBlocks() + self.updateSections() + if self.currentSection: + self.updateBlocks(self.currentSection) self.style.font = ( 'FreeSans' if self.style.useIncludedFont else 'HelveticaNeue') @@ -713,7 +856,7 @@ class UnreadableMessageBox(QMessageBox): self.setDefaultButton(QMessageBox.Ok) -class NameWarningMessageBox(QMessageBox): +class ChordNameWarningMessageBox(QMessageBox): """ Message box to warn the user that a chord must have a name """ @@ -729,6 +872,23 @@ class NameWarningMessageBox(QMessageBox): self.setDefaultButton(QMessageBox.Ok) +class SectionNameWarningMessageBox(QMessageBox): + """ + Message box to warn the user that a section must have a name + """ + + def __init__(self): + super().__init__() + + self.setIcon(QMessageBox.Warning) + self.setWindowTitle("Unnamed section") + self.setText("Sections must have a unique name.") + self.setInformativeText( + "Please give your section a unique name and try again.") + self.setStandardButtons(QMessageBox.Ok) + self.setDefaultButton(QMessageBox.Ok) + + class VoicingWarningMessageBox(QMessageBox): """ Message box to warn the user that the voicing entered could not be parsed diff --git a/test.py b/test.py index 746669b..f91d85c 100644 --- a/test.py +++ b/test.py @@ -1,12 +1,15 @@ +import os + from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from chordsheet.document import Document, Style -from chordsheet.render import savePDF +from chordsheet.render import Renderer pdfmetrics.registerFont(TTFont('FreeSans', os.path.join('fonts', 'FreeSans.ttf'))) -doc = Document.newFromXML('examples/example.xml') -style = Style() +doc = Document.newFromXML('examples/angela.xml') +style = Style(unitWidth=20) +ren = Renderer(doc, style) -savePDF(doc, style, 'test.pdf') \ No newline at end of file +ren.savePDF('test.pdf') \ No newline at end of file diff --git a/ui/mainwindow.ui b/ui/mainwindow.ui index c52fd9d..6f12a3d 100644 --- a/ui/mainwindow.ui +++ b/ui/mainwindow.ui @@ -7,7 +7,7 @@ 0 0 1061 - 646 + 659 @@ -268,14 +268,14 @@ - + Top margin - + @@ -285,6 +285,40 @@ + + + + Right margin + + + + + + + + 60 + 16777215 + + + + + + + + Bottom margin + + + + + + + + 60 + 16777215 + + + + @@ -569,6 +603,112 @@ + + + Sections + + + + + + + + + 0 + 0 + + + + QAbstractItemView::NoEditTriggers + + + true + + + false + + + QAbstractItemView::InternalMove + + + Qt::TargetMoveAction + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + false + + + false + + + + + + + QFormLayout::ExpandingFieldsGrow + + + + + Name + + + + + + + + + + + + + + Remove section + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 0 + 20 + + + + + + + + Update section + + + + + + + Add section + + + + + + + + + Blocks @@ -576,6 +716,30 @@ + + + + QFormLayout::ExpandingFieldsGrow + + + + + Section + + + + + + + + 0 + 0 + + + + + + @@ -783,7 +947,7 @@ - + 1 @@ -796,52 +960,9 @@ 400 - + true - - - - 0 - 0 - 598 - 598 - - - - true - - - - 12 - - - 12 - - - 12 - - - 12 - - - - - true - - - - - - false - - - 0 - - - - - @@ -974,6 +1095,22 @@ QTableView
chordsheet/tableView.h
+ + SectionTableView + QTableView +
chordsheet/tableView.h
+
+ + MComboBox + QComboBox +
chordsheet/comboBox.h
+
+ + PDFViewer + QWidget +
chordsheet/pdfViewer.h
+ 1 +
generateButton @@ -996,7 +1133,6 @@ blockTableView addBlockButton removeBlockButton - scrollArea