diff --git a/nw/constants.py b/nw/constants.py index 9c56b012..a5244d46 100644 --- a/nw/constants.py +++ b/nw/constants.py @@ -19,6 +19,7 @@ class nwFiles(): PROJ_DICT = "wordlist.txt" SESS_INFO = "sessionInfo.log" INDEX_FILE = "tagsIndex.json" + EXPORT_OPT = "exportOptions.json" # END Class nwFiles diff --git a/nw/convert/textfile.py b/nw/convert/textfile.py new file mode 100644 index 00000000..621b9dec --- /dev/null +++ b/nw/convert/textfile.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""novelWriter Text File + + novelWriter – Text File +========================= + Writes the project to a plain text file + + File History: + Created: 2019-10-18 [0.2.3] + +""" + +import logging +import nw + +from os import path +from PyQt5.QtWidgets import QMessageBox + +from nw.convert.tokenizer import Tokenizer +from nw.enum import nwAlert, nwItemLayout + +logger = logging.getLogger(__name__) + +class TextFile(): + + def __init__(self, theProject, theParent): + + self.mainConf = nw.CONFIG + self.theProject = theProject + self.theParent = theParent + self.fileExt = "txt" + + self.outFile = None + self.fileName = "" + self.theText = "" + self.expNovel = True + self.expNotes = False + self.winEnding = False + + self.theConv = Tokenizer(self.theProject, self.theParent) + self.makeAlert = self.theParent.makeAlert + + self.setComments(False) + self.setMeta(False) + self.setWordWrap(80) + + return + + ## + # Setters + ## + + def setExportNovel(self, doNovel): + self.expNovel = doNovel + return + + def setExportNotes(self, doNotes): + self.expNotes = doNotes + return + + def setComments(self, doComments): + self.theConv.setComments(doComments) + return + + def setMeta(self, doMeta): + self.theConv.setCommands(doMeta) + return + + def setWordWrap(self, wordWrap): + if wordWrap >= 0: + self.theConv.setWordWrap(wordWrap) + else: + self.theConv.setWordWrap(0) + return + + def setTitleFormat(self, fmtTitle): + self.theConv.setTitleFormat(fmtTitle) + return + + def setChapterFormat(self, fmtChapter): + self.theConv.setChapterFormat(fmtChapter) + return + + def setUnNumberedFormat(self, fmtUnNum): + self.theConv.setUnNumberedFormat(fmtUnNum) + return + + def setSceneFormat(self, fmtScene): + self.theConv.setSceneFormat(fmtScene) + return + + def setSectionFormat(self, fmtSection): + self.theConv.setSectionFormat(fmtSection) + return + + ## + # Core Methods + ## + + def openFile(self, filePath): + + self.fileName = path.basename(filePath) + if path.isfile(filePath) and self.mainConf.showGUI: + msgBox = QMessageBox() + msgRes = msgBox.question( + self.theParent, "Overwrite", ("File '%s' already exists.
Do you want to overwrite it?" % self.fileName) + ) + if msgRes != QMessageBox.Yes: + return False + + self._doOpenFile(filePath) + + if self.outFile is None: + return False + + return True + + def closeFile(self): + self._doCloseFile() + return True + + def addText(self, tHandle): + + logger.verbose("Parsing content of item '%s'" % tHandle) + + theItem = self.theProject.getItem(tHandle) + isNone = theItem.itemLayout == nwItemLayout.NO_LAYOUT + isNote = theItem.itemLayout == nwItemLayout.NOTE + isNovel = not isNone and not isNote + + if isNone: + return False + if isNote and not self.expNotes: + return False + if isNovel and not self.expNovel: + return False + + self.theConv.setText(tHandle) + self.theConv.doAutoReplace() + self.theConv.tokenizeText() + self.theConv.doHeaders() + self.theConv.doConvert() + + if self.winEnding: + self.theConv.windowsEndings() + + if self.theConv.theResult is not None and self.outFile is not None: + self.outFile.write(self.theConv.theResult) + + return True + + ## + # Internal Functions + ## + + def _doOpenFile(self, filePath): + """This function does the actual opening of the file, and can be overloaded by a subclass + that uses a different file format that requires a different approach. + """ + try: + self.outFile = open(filePath,mode="w+") + if self.winEnding: + self.outFile.write("\r\n\r\n") + else: + self.outFile.write("\n\n") + except Exception as e: + self.makeAlert(["Failed to open file.",str(e)], nwAlert.ERROR) + return False + return True + + def _doCloseFile(self): + """This function closes a file, and is meant to be overloaded by the subclass for other + file formats. + """ + if self.outFile is not None: + self.outFile.close() + return True + +# END Class OutFile diff --git a/nw/convert/tohtml.py b/nw/convert/tohtml.py index c6ec8975..0f1e0612 100644 --- a/nw/convert/tohtml.py +++ b/nw/convert/tohtml.py @@ -51,21 +51,21 @@ class ToHtml(Tokenizer): self.theResult = "" thisPar = [] - for tType, tText, tFormat in self.theTokens: + for tType, tText, tFormat, tAlign in self.theTokens: - if tType == "empty": + if tType == self.T_EMPTY: if len(thisPar) > 0: self.theResult += "

%s

\n" % " ".join(thisPar) thisPar = [] - elif tType == "header1": + elif tType == self.T_HEAD1: self.theResult += "

%s

\n" % tText - elif tType == "header2": + elif tType == self.T_HEAD2: self.theResult += "

%s

\n" % tText - elif tType == "header3": + elif tType == self.T_HEAD3: self.theResult += "

%s

\n" % tText - elif tType == "header4": + elif tType == self.T_HEAD4: self.theResult += "

%s

\n" % tText - elif tType == "text": + elif tType == self.T_TEXT: tTemp = tText for xPos, xLen, xFmt in reversed(tFormat): tTemp = tTemp[:xPos]+htmlTags[xFmt]+tTemp[xPos+xLen:] diff --git a/nw/convert/tokenizer.py b/nw/convert/tokenizer.py index 0d62175f..c30734bb 100644 --- a/nw/convert/tokenizer.py +++ b/nw/convert/tokenizer.py @@ -10,24 +10,43 @@ """ +import textwrap import logging import re import nw from operator import itemgetter from PyQt5.QtCore import QRegularExpression + from nw.project.document import NWDoc +from nw.tools.translate import numberToWord +from nw.enum import nwItemLayout logger = logging.getLogger(__name__) class Tokenizer(): - FMT_B_B = 1 # Begin Bold - FMT_B_E = 2 # End Bold - FMT_I_B = 3 # Begin Italics - FMT_I_E = 4 # End Italics - FMT_U_B = 5 # Begin Underline - FMT_U_E = 6 # End Underline + FMT_B_B = "" # Begin bold + FMT_B_E = "" # End bold + FMT_I_B = "" # Begin italics + FMT_I_E = "" # End italics + FMT_U_B = "" # Begin underline + FMT_U_E = "" # End underline + + T_EMPTY = 1 # Empty line (new paragraph) + T_COMMENT = 2 # Comment line + T_COMMAND = 3 # Command line + T_HEAD1 = 4 # Header 1 (title) + T_HEAD2 = 5 # Header 2 (chapter) + T_HEAD3 = 6 # Header 3 (scene) + T_HEAD4 = 7 # Header 4 + T_TEXT = 8 # Text line + T_SEP = 9 # Scene separator + + A_LEFT = 1 # Left aligned + A_RIGHT = 2 # Right aligned + A_CENTRE = 3 # Centred + A_JUSTIFY = 4 # Justified def __init__(self, theProject, theParent): @@ -41,8 +60,66 @@ class Tokenizer(): self.theTokens = None self.theResult = None + self.wordWrap = 80 + self.doComments = False + self.doCommands = False + + self.fmtTitle = "%title%" + self.fmtChapter = "Chapter %numword%: %title%" + self.fmtUnNum = "%title%" + self.fmtScene = "* * *" + self.fmtSection = "%title%" + + self.noSection = True + + self.numChapter = 0 + self.firstScene = False + return + ## + # Setters + ## + + def setComments(self, doComments): + self.doComments = doComments + return + + def setCommands(self, doCommands): + self.doCommands = doCommands + return + + def setWordWrap(self, wordWrap): + if wordWrap >= 0: + self.wordWrap = wordWrap + else: + self.wordWrap = 0 + return + + def setTitleFormat(self, fmtTitle): + self.fmtTitle = fmtTitle + return + + def setChapterFormat(self, fmtChapter): + self.fmtChapter = fmtChapter + return + + def setUnNumberedFormat(self, fmtUnNum): + self.fmtUnNum = fmtUnNum + return + + def setSceneFormat(self, fmtScene): + self.fmtScene = fmtScene + return + + def setSectionFormat(self, fmtSection): + self.fmtSection = fmtSection + return + + ## + # Class Methods + ## + def setText(self, theHandle, theText=None): self.theHandle = theHandle @@ -93,19 +170,19 @@ class Tokenizer(): # Tag lines starting with specific characters if len(aLine) == 0: - self.theTokens.append(("empty","",None)) + self.theTokens.append((self.T_EMPTY,"",None,self.A_LEFT)) elif aLine[0] == "%": - self.theTokens.append(("comment",aLine[1:].strip(),None)) + self.theTokens.append((self.T_COMMENT,aLine[1:].strip(),None,self.A_LEFT)) elif aLine[0] == "@": - self.theTokens.append(("command",aLine[1:].strip(),None)) + self.theTokens.append((self.T_COMMAND,aLine[1:].strip(),None,self.A_LEFT)) elif aLine[:2] == "# ": - self.theTokens.append(("header1",aLine[2:].strip(),None)) + self.theTokens.append((self.T_HEAD1,aLine[2:].strip(),None,self.A_LEFT)) elif aLine[:3] == "## ": - self.theTokens.append(("header2",aLine[3:].strip(),None)) + self.theTokens.append((self.T_HEAD2,aLine[3:].strip(),None,self.A_LEFT)) elif aLine[:4] == "### ": - self.theTokens.append(("header3",aLine[4:].strip(),None)) + self.theTokens.append((self.T_HEAD3,aLine[4:].strip(),None,self.A_LEFT)) elif aLine[:5] == "#### ": - self.theTokens.append(("header4",aLine[5:].strip(),None)) + self.theTokens.append((self.T_HEAD4,aLine[5:].strip(),None,self.A_LEFT)) else: # Otherwise we use RegEx to find formatting tags within a line of text fmtPos = [] @@ -121,12 +198,209 @@ class Tokenizer(): # Save the line as is, but append the array of formatting locations sorted by position fmtPos = sorted(fmtPos,key=itemgetter(0)) - self.theTokens.append(("text",aLine,fmtPos)) + self.theTokens.append((self.T_TEXT,aLine,fmtPos,self.A_LEFT)) # Always add an empty line at the end - self.theTokens.append(("empty","",None)) - # print(self.theTokens) + self.theTokens.append((self.T_EMPTY,"",None,self.A_LEFT)) return + def doHeaders(self): + + isNone = self.theItem.itemLayout == nwItemLayout.NO_LAYOUT + isTitle = self.theItem.itemLayout == nwItemLayout.TITLE + isBook = self.theItem.itemLayout == nwItemLayout.BOOK + isPage = self.theItem.itemLayout == nwItemLayout.PAGE + isPart = self.theItem.itemLayout == nwItemLayout.PARTITION + isUnNum = self.theItem.itemLayout == nwItemLayout.UNNUMBERED + isChap = self.theItem.itemLayout == nwItemLayout.CHAPTER + isScene = self.theItem.itemLayout == nwItemLayout.SCENE + isNote = self.theItem.itemLayout == nwItemLayout.NOTE + + # No special header formatting for notes and no layout files + if isNone: return + if isNote: return + + # For novel files, we need to handle chapter numbering and scene breaks + if isBook or isUnNum or isChap or isScene: + for n in range(len(self.theTokens)): + + tToken = self.theTokens[n] + tType = tToken[0] + tText = tToken[1] + + if tType == self.T_TEXT: + self.firstScene = False + + elif tType == self.T_HEAD2: + if not isUnNum: + self.numChapter += 1 + tText = self._doFormatChapter(tText,isUnNum) + self.theTokens[n] = (tType,tText,None,self.A_LEFT) + self.firstScene = True + + elif tType == self.T_HEAD3: + tTemp = self._doFormatScene(tText) + if tTemp == "": + self.theTokens[n] = (self.T_EMPTY,"",None,self.A_LEFT) + elif tTemp == self.fmtScene: + if self.firstScene: + self.theTokens[n] = (self.T_EMPTY,"",None,self.A_LEFT) + else: + self.theTokens[n] = (self.T_SEP,tTemp,None,self.A_LEFT) + else: + self.theTokens[n] = (tType,tTemp,None,self.A_LEFT) + self.firstScene = False + + elif tType == self.T_HEAD4: + tTemp = self._doFormatSection(tText) + if tTemp == "": + self.theTokens[n] = (self.T_EMPTY,"",None,self.A_LEFT) + elif tTemp == self.fmtSection: + self.theTokens[n] = (self.T_SEP,tTemp,None,self.A_LEFT) + else: + self.theTokens[n] = (tType,tTemp,None,self.A_LEFT) + + # For title page and partitions, we need to centre all text + if isTitle or isPart: + for n in range(len(self.theTokens)): + tToken = self.theTokens[n] + tType = tToken[0] + tText = tToken[1] + tFormat = tToken[2] + self.theTokens[n] = (tType,tText,tFormat,self.A_CENTRE) + + return + + def doConvert(self): + """Converts the tokenized text into plain text. + """ + + if self.wordWrap > 0: + tWrap = textwrap.TextWrapper( + width = self.wordWrap, + initial_indent = "", + subsequent_indent = "", + expand_tabs = True, + replace_whitespace = True, + fix_sentence_endings = False, + break_long_words = True, + drop_whitespace = True, + break_on_hyphens = True, + tabsize = 8, + max_lines = None + ) + + self.theResult = "" + thisPar = [] + for tType, tText, tFormat, tAlign in self.theTokens: + + # First check if we have a comment or plain text, as they need some + # extra replacing before we proceed to wrapping and final formatting. + if tType == self.T_COMMENT: + tText = "[%s]" % tText + + elif tType == self.T_TEXT: + tTemp = tText + for xPos, xLen, xFmt in reversed(tFormat): + tTemp = tTemp[:xPos]+tTemp[xPos+xLen:] + tText = tTemp + + tLen = len(tText) + + # The text can now be word wrapped, if we have requested this and it's needed. + if tAlign == self.A_CENTRE: + if self.wordWrap > 0: + if tLen > self.wordWrap: + aText = tWrap.wrap(tText) + for n in range(len(aText)): + aText[n] = self._centreText(aText[n],self.wordWrap) + tText = "\n".join(aText) + else: + tText = self._centreText(tText,self.wordWrap) + else: + if self.wordWrap > 0 and tLen > self.wordWrap: + tText = tWrap.fill(tText) + + # Then the text can receive final formatting before we append it to the results. + # We also store text lines in a buffer and merge them only when we find an empty line, + # indicating a new paragraph. + if tType == self.T_EMPTY: + if len(thisPar) > 0: + self.theResult += "%s\n\n" % " ".join(thisPar) + thisPar = [] + + elif tType == self.T_HEAD1: + uLine = "="*min(tLen,self.wordWrap) + if tAlign == self.A_CENTRE: + uLine = self._centreText(uLine,self.wordWrap) + self.theResult += "%s\n%s\n\n" % (tText,uLine) + + elif tType == self.T_HEAD2: + uLine = "~"*min(tLen,self.wordWrap) + self.theResult += "%s\n%s\n\n" % (tText,uLine) + + elif tType == self.T_HEAD3: + uLine = "-"*min(tLen,self.wordWrap) + self.theResult += "%s\n%s\n\n" % (tText,uLine) + + elif tType == self.T_HEAD4: + self.theResult += "%s\n\n" % tText + + elif tType == self.T_SEP: + if self.wordWrap > 0 and tLen < self.wordWrap: + tText = self._centreText(tText,self.wordWrap) + self.theResult += "%s\n\n" % tText + + elif tType == self.T_TEXT: + thisPar.append(tText) + + elif tType == self.T_COMMENT and self.doComments: + self.theResult += "%s\n\n" % tText + + elif tType == self.T_COMMAND and self.doCommands: + self.theResult += "%s\n\n" % tText + + return + + def windowsEndings(self): + self.theResult = self.theResult.replace("\n","\r\n") + return + + ## + # Internal Functions + ## + + def _doFormatTitle(self, theText): + theTitle = self.fmtTitle + theTitle = theTitle.replace("%title%", theText) + return theTitle + + def _doFormatChapter(self, theText, noNum): + if noNum: + theTitle = self.fmtUnNum + theTitle = theTitle.replace("%title%", theText) + else: + theTitle = self.fmtChapter + theTitle = theTitle.replace("%title%", theText) + theTitle = theTitle.replace("%num%", str(self.numChapter)) + theTitle = theTitle.replace("%numword%", numberToWord(self.numChapter,"en")) + return theTitle + + def _doFormatScene(self, theText): + theTitle = self.fmtScene + theTitle = theTitle.replace("%title%", theText) + return theTitle + + def _doFormatSection(self, theText): + theTitle = self.fmtSection + theTitle = theTitle.replace("%title%", theText) + return theTitle + + def _centreText(self, theText, theWidth): + tLen = len(theText) + if tLen < theWidth: + return " "*int((theWidth-tLen)/2) + theText + return theText + # END Class Tokenizer diff --git a/nw/graphics/export.svg b/nw/graphics/export.svg new file mode 100644 index 00000000..9594a980 --- /dev/null +++ b/nw/graphics/export.svg @@ -0,0 +1,44 @@ + +image/svg+xml \ No newline at end of file diff --git a/nw/graphics/export.txt b/nw/graphics/export.txt new file mode 100644 index 00000000..1a65e8da --- /dev/null +++ b/nw/graphics/export.txt @@ -0,0 +1,3 @@ +FROM ICON SET: Typicons +LICENSE: Creative Commons (Attribution-Share Alike 3.0 Unported) +https://creativecommons.org/licenses/by-sa/3.0/ diff --git a/nw/gui/configeditor.py b/nw/gui/configeditor.py index 69376a78..5010e500 100644 --- a/nw/gui/configeditor.py +++ b/nw/gui/configeditor.py @@ -68,10 +68,14 @@ class GuiConfigEditor(QDialog): self.show() - logger.debug("ProjectEditor ConfigEditor complete") + logger.debug("ConfigEditor initialisation complete") return + ## + # Buttons + ## + def _doSave(self): logger.verbose("ConfigEditor save button clicked") @@ -111,10 +115,10 @@ class GuiConfigEditGeneral(QWidget): def __init__(self, theParent): QWidget.__init__(self, theParent) - self.mainConf = nw.CONFIG - self.theParent = theParent - self.theTheme = theParent.theTheme - self.outerBox = QGridLayout() + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theTheme = theParent.theTheme + self.outerBox = QGridLayout() # User Interface self.guiLook = QGroupBox("User Interface", self) @@ -278,6 +282,7 @@ class GuiConfigEditGeneral(QWidget): if newDir: self.projBackupPath.setText(newDir) return True + return False # END Class GuiConfigEditGeneral diff --git a/nw/gui/docdetails.py b/nw/gui/docdetails.py index 804b43bc..888e295e 100644 --- a/nw/gui/docdetails.py +++ b/nw/gui/docdetails.py @@ -54,7 +54,7 @@ class GuiDocDetails(QFrame): QLabel("") ] - colOne = ["Name","Status","Class","Layout"] + colOne = ["Label","Status","Class","Layout"] for nRow in range(4): lblOne = QLabel(colOne[nRow]) lblOne.setFont(self.fntOne) diff --git a/nw/gui/export.py b/nw/gui/export.py new file mode 100644 index 00000000..e79ce681 --- /dev/null +++ b/nw/gui/export.py @@ -0,0 +1,507 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Export Tools + + novelWriter – GUI Export Tools +================================ + Tool for exporting project files to other formats + + File History: + Created: 2019-10-13 [0.2.3] + +""" + +import logging +import time +import json +import nw + +from os import path + +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtSvg import QSvgWidget +from PyQt5.QtWidgets import ( + QDialog, QHBoxLayout, QVBoxLayout, QWidget, QTabWidget, QGridLayout, QGroupBox, QCheckBox, + QLabel, QComboBox, QLineEdit, QPushButton, QFileDialog, QProgressBar, QSpinBox +) + +from nw.project.document import NWDoc +from nw.tools.translate import numberToWord +from nw.convert.textfile import TextFile +from nw.common import checkString, checkBool, checkInt +from nw.constants import nwFiles +from nw.enum import nwItemType + +logger = logging.getLogger(__name__) + +class GuiExport(QDialog): + + def __init__(self, theParent, theProject): + QDialog.__init__(self, theParent) + + logger.debug("Initialising GuiExport ...") + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + self.optState = ExportLastState(self.theProject) + + self.outerBox = QHBoxLayout() + self.innerBox = QVBoxLayout() + self.setWindowTitle("Export Project") + self.setLayout(self.outerBox) + + self.gradPath = path.abspath(path.join(self.mainConf.appPath,"graphics","export.svg")) + self.svgGradient = QSvgWidget(self.gradPath) + self.svgGradient.setFixedSize(QSize(64,64)) + + self.theProject.countStatus() + self.tabMain = GuiExportMain(self.theParent, self.theProject, self.optState) + + self.tabWidget = QTabWidget() + self.tabWidget.addTab(self.tabMain, "Settings") + + self.outerBox.addWidget(self.svgGradient, 0, Qt.AlignTop) + self.outerBox.addLayout(self.innerBox) + + self.doExportForm = QGridLayout() + self.doExportForm.setContentsMargins(10,5,0,10) + + self.exportButton = QPushButton("Export") + self.exportButton.clicked.connect(self._doExport) + + self.closeButton = QPushButton("Close") + self.closeButton.clicked.connect(self._doClose) + + self.exportStatus = QLabel("Ready ...") + self.exportProgress = QProgressBar(self) + + self.doExportForm.addWidget(self.exportStatus, 0, 0, 1, 3) + self.doExportForm.addWidget(self.exportProgress, 1, 0) + self.doExportForm.addWidget(self.exportButton, 1, 1) + self.doExportForm.addWidget(self.closeButton, 1, 2) + + self.innerBox.addWidget(self.tabWidget) + self.innerBox.addLayout(self.doExportForm) + + self.rejected.connect(self._doClose) + self.show() + + logger.debug("GuiExport initialisation complete") + + return + + ## + # Buttons + ## + + def _doExport(self): + + logger.verbose("GuiExport export button clicked") + + wNovel = self.tabMain.expNovel.isChecked() + wNotes = self.tabMain.expNotes.isChecked() + eFormat = self.tabMain.outputFormat.currentData() + fixWidth = self.tabMain.fixedWidth.value() + wComments = self.tabMain.expComments.isChecked() + chFormat = self.tabMain.chapterFormat.text() + unFormat = self.tabMain.unnumFormat.text() + scFormat = self.tabMain.sceneFormat.text() + seFormat = self.tabMain.sectionFormat.text() + saveTo = self.tabMain.exportPath.text() + + nItems = len(self.theProject.treeOrder) + self.exportProgress.setMinimum(0) + self.exportProgress.setMaximum(nItems) + self.exportProgress.setValue(0) + + if not wNovel and not wNotes: + self.exportStatus.setText("Nothing to export ...") + return False + + outFile = None + if eFormat == GuiExportMain.FMT_TXT: + outFile = TextFile(self.theProject, self.theParent) + + if outFile is None: + return False + + if outFile.openFile(saveTo): + outFile.setComments(wComments) + outFile.setExportNovel(wNovel) + outFile.setExportNotes(wNotes) + outFile.setWordWrap(fixWidth) + outFile.setChapterFormat(chFormat) + outFile.setUnNumberedFormat(unFormat) + outFile.setSceneFormat(scFormat) + outFile.setSectionFormat(seFormat) + else: + self.exportStatus.setText("Failed to open file for writing ...") + return False + + nDone = 0 + for tHandle in self.theProject.treeOrder: + + time.sleep(0.1) + + self.exportProgress.setValue(nDone) + tItem = self.theProject.getItem(tHandle) + + self.exportStatus.setText("Exporting: %s" % tItem.itemName) + logger.verbose("Exporting: %s" % tItem.itemName) + + if tItem is not None and tItem.itemType == nwItemType.FILE: + outFile.addText(tHandle) + + nDone += 1 + + outFile.closeFile() + + self.exportProgress.setValue(nDone) + self.exportStatus.setText("Export to %s complete" % outFile.fileName) + logger.verbose("Export to %s complete" % outFile.fileName) + + return + + def _doClose(self): + + logger.verbose("GuiExport close button clicked") + + wNovel = self.tabMain.expNovel.isChecked() + wNotes = self.tabMain.expNotes.isChecked() + eFormat = self.tabMain.outputFormat.currentData() + fixWidth = self.tabMain.fixedWidth.value() + wComments = self.tabMain.expComments.isChecked() + chFormat = self.tabMain.chapterFormat.text() + unFormat = self.tabMain.unnumFormat.text() + scFormat = self.tabMain.sceneFormat.text() + seFormat = self.tabMain.sectionFormat.text() + saveTo = self.tabMain.exportPath.text() + + self.optState.setSetting("wNovel", wNovel) + self.optState.setSetting("wNotes", wNotes) + self.optState.setSetting("eFormat", eFormat) + self.optState.setSetting("fixWidth", fixWidth) + self.optState.setSetting("wComments",wComments) + self.optState.setSetting("chFormat", chFormat) + self.optState.setSetting("unFormat", unFormat) + self.optState.setSetting("scFormat", scFormat) + self.optState.setSetting("seFormat", seFormat) + self.optState.setSetting("saveTo", saveTo) + + self.optState.saveSettings() + self.close() + + return + +# END Class GuiExport + +class GuiExportMain(QWidget): + + FMT_TXT = 1 + FMT_MD = 2 + FMT_HTML = 3 + FMT_EBOOK = 4 + FMT_ODT = 5 + FMT_TEX = 6 + FMT_EXT = { + FMT_TXT : ".txt", + FMT_MD : ".md", + FMT_HTML : ".htm", + FMT_EBOOK : ".htm", + FMT_ODT : ".odt", + FMT_TEX : ".tex", + } + FMT_HELP = { + FMT_TXT : ( + "Exports a plain text file. " + "All formatting is stripped and comments are in square brackets." + ), + FMT_MD : ( + "Exports a standard markdown file. " + "Comments are converted to preformatted text blocks." + ), + FMT_HTML : ( + "Exports a plain html5 file. " + "Comments are converted to preformatted text blocks." + ), + FMT_EBOOK : ( + "Exports an html5 file that can be converted to eBook with Calibre. " + "Comments are not exported in this format." + ), + FMT_ODT : ( + "Exports an open document file that can be read by office applications. " + "Comments are exported as grey text." + ), + FMT_TEX : ( + "Exports a LaTeX file that can be compiled to PDF using for instance PDFLaTeX. " + "Comments are exported as LaTeX comments." + ), + } + + def __init__(self, theParent, theProject, optState): + QWidget.__init__(self, theParent) + + self.theParent = theParent + self.theProject = theProject + self.theTheme = theParent.theTheme + self.outerBox = QGridLayout() + self.optState = optState + self.currFormat = self.FMT_TXT + + # Select Files + self.guiFiles = QGroupBox("Selection", self) + self.guiFilesForm = QGridLayout(self) + self.guiFiles.setLayout(self.guiFilesForm) + + self.expNovel = QCheckBox(self) + self.expNovel.setChecked(self.optState.getSetting("wNovel")) + self.expNovel.setToolTip("Include all novel files in the exported document") + + self.expNotes = QCheckBox(self) + self.expNotes.setChecked(self.optState.getSetting("wNotes")) + self.expNotes.setToolTip("Include all note files in the exported document") + + self.expComments = QCheckBox(self) + self.expComments.setChecked(self.optState.getSetting("wComments")) + self.expComments.setToolTip("Export comments from all files") + + self.guiFilesForm.addWidget(QLabel("Novel files"), 0, 0) + self.guiFilesForm.addWidget(self.expNovel, 0, 1) + self.guiFilesForm.addWidget(QLabel("Note files"), 1, 0) + self.guiFilesForm.addWidget(self.expNotes, 1, 1) + self.guiFilesForm.addWidget(QLabel("Comments"), 2, 0) + self.guiFilesForm.addWidget(self.expComments, 2, 1) + + # Chapter Settings + self.guiChapters = QGroupBox("Chapter Headings", self) + self.guiChaptersForm = QGridLayout(self) + self.guiChapters.setLayout(self.guiChaptersForm) + + self.chapterFormat = QLineEdit() + self.chapterFormat.setText(self.optState.getSetting("chFormat")) + self.chapterFormat.setToolTip("Available formats: %num%, %numword%, %title%") + self.chapterFormat.setMinimumWidth(250) + + self.unnumFormat = QLineEdit() + self.unnumFormat.setText(self.optState.getSetting("unFormat")) + self.unnumFormat.setToolTip("Available formats: %title%") + self.unnumFormat.setMinimumWidth(250) + + self.guiChaptersForm.addWidget(QLabel("Numbered"), 0, 0) + self.guiChaptersForm.addWidget(self.chapterFormat, 0, 1) + self.guiChaptersForm.addWidget(QLabel("Unnumbered"), 1, 0) + self.guiChaptersForm.addWidget(self.unnumFormat, 1, 1) + + # Scene and Section Settings + self.guiScenes = QGroupBox("Other Headings", self) + self.guiScenesForm = QGridLayout(self) + self.guiScenes.setLayout(self.guiScenesForm) + + self.sceneFormat = QLineEdit() + self.sceneFormat.setText(self.optState.getSetting("scFormat")) + self.sceneFormat.setToolTip("Available formats: %title%") + self.sceneFormat.setMinimumWidth(100) + + self.sectionFormat = QLineEdit() + self.sectionFormat.setText(self.optState.getSetting("seFormat")) + self.sectionFormat.setToolTip("Available formats: %title%") + self.sectionFormat.setMinimumWidth(100) + + self.guiScenesForm.addWidget(QLabel("Scenes"), 0, 0) + self.guiScenesForm.addWidget(self.sceneFormat, 0, 1) + self.guiScenesForm.addWidget(QLabel("Sections"), 1, 0) + self.guiScenesForm.addWidget(self.sectionFormat, 1, 1) + + # Output Path + self.exportTo = QGroupBox("Export Folder", self) + self.exportToForm = QGridLayout(self) + self.exportTo.setLayout(self.exportToForm) + + self.exportPath = QLineEdit(self.optState.getSetting("saveTo")) + + self.exportGetPath = QPushButton(self.theTheme.getIcon("folder"),"") + self.exportGetPath.clicked.connect(self._exportFolder) + + self.exportToForm.addWidget(QLabel("Save to"), 0, 0) + self.exportToForm.addWidget(self.exportPath, 0, 1) + self.exportToForm.addWidget(self.exportGetPath, 0, 2) + + # Output Format + self.guiOutput = QGroupBox("Export", self) + self.guiOutputForm = QGridLayout(self) + self.guiOutput.setLayout(self.guiOutputForm) + + self.outputHelp = QLabel("") + self.outputHelp.setWordWrap(True) + self.outputHelp.setMinimumHeight(55) + self.outputHelp.setAlignment(Qt.AlignTop) + + self.outputFormat = QComboBox(self) + self.outputFormat.addItem("Plain Text (.txt)", self.FMT_TXT) + # self.outputFormat.addItem("Markdown (.md)", self.FMT_MD) + # self.outputFormat.addItem("HTML5 (.htm)", self.FMT_HTML) + # self.outputFormat.addItem("HTML5 for eBook (.htm)", self.FMT_EBOOK) + # self.outputFormat.addItem("Open Document (.odt)", self.FMT_ODT) + # self.outputFormat.addItem("LaTeX for PDF (.tex)", self.FMT_TEX) + self.outputFormat.currentIndexChanged.connect(self._updateFormat) + + optIdx = self.outputFormat.findData(self.optState.getSetting("eFormat")) + if optIdx != -1: + self.outputFormat.setCurrentIndex(optIdx) + self._updateFormat(optIdx) + + self.guiOutputForm.addWidget(QLabel("Format"), 0, 0) + self.guiOutputForm.addWidget(self.outputFormat, 0, 1) + self.guiOutputForm.addWidget(self.outputHelp, 1, 0, 1, 3) + self.guiOutputForm.setColumnStretch(2, 1) + + # Additional Settings + self.addSettings = QGroupBox("Additional Settings", self) + self.addSettingsForm = QGridLayout(self) + self.addSettings.setLayout(self.addSettingsForm) + + self.fixedWidth = QSpinBox(self) + self.fixedWidth.setMinimum(0) + self.fixedWidth.setMaximum(999) + self.fixedWidth.setSingleStep(1) + self.fixedWidth.setValue(self.optState.getSetting("fixWidth")) + self.fixedWidth.setToolTip("0 disables the feature. Applies to .txt, .md and .tex files.") + + self.addSettingsForm.addWidget(QLabel("Fixed width"), 0, 0) + self.addSettingsForm.addWidget(self.fixedWidth, 0, 1) + self.addSettingsForm.setColumnStretch(2, 1) + + # Assemble + self.outerBox.addWidget(self.guiOutput, 0, 0, 1, 2) + self.outerBox.addWidget(self.guiFiles, 0, 2) + self.outerBox.addWidget(self.guiChapters, 1, 0, 1, 2) + self.outerBox.addWidget(self.guiScenes, 1, 2) + self.outerBox.addWidget(self.addSettings, 2, 0, 1, 3) + self.outerBox.addWidget(self.exportTo, 3, 0, 1, 3) + self.outerBox.setColumnStretch(0, 1) + self.outerBox.setColumnStretch(1, 1) + self.outerBox.setColumnStretch(2, 1) + self.setLayout(self.outerBox) + + return + + ## + # Internal Functions + ## + + def _updateFormat(self, currIdx): + """Update help text under output format selection and file extension in file box + """ + if currIdx == -1: + self.outputHelp.setText("") + else: + self.currFormat = self.outputFormat.itemData(currIdx) + self.outputHelp.setText("%s" % self.FMT_HELP[self.currFormat]) + self._checkFileExtension() + return + + def _exportFolder(self): + + currDir = self.exportPath.text() + if not path.isdir(currDir): + currDir = "" + + extFilter = [ + "Text files (*.txt)", + "Markdown files (*.md)", + "HTML files (*.htm *.html)", + "Open document files (*.odt)", + "LaTeX files (*.tex)", + ] + + dlgOpt = QFileDialog.Options() + dlgOpt |= QFileDialog.ShowDirsOnly + dlgOpt |= QFileDialog.DontUseNativeDialog + saveTo = QFileDialog.getSaveFileName( + self,"Export File",self.exportPath.text(),options=dlgOpt,filter=";;".join(extFilter) + ) + if saveTo: + self.exportPath.setText(saveTo[0]) + self._checkFileExtension() + return True + + return False + + def _checkFileExtension(self): + saveTo = self.exportPath.text() + fileBits = path.splitext(saveTo) + if self.currFormat > 0: + saveTo = fileBits[0]+self.FMT_EXT[self.currFormat] + self.exportPath.setText(saveTo) + return + +# END Class GuiExportMain + +class ExportLastState(): + + def __init__(self, theProject): + self.theProject = theProject + self.theState = { + "wNovel" : True, + "wNotes" : False, + "eFormat" : 2, + "fixWidth" : 80, + "wComments" : False, + "chFormat" : "Chapter %numword%", + "unFormat" : "%title%", + "scFormat" : "* * *", + "seFormat" : "", + "saveTo" : "", + } + self.stringOpt = ("chFormat","unFormat","scFormat","seFormat","saveTo") + self.boolOpt = ("wNovel","wNotes","wComments") + self.intOpt = ("eFormat","fixWidth") + self.loadSettings() + return + + def loadSettings(self): + stateFile = path.join(self.theProject.projMeta, nwFiles.EXPORT_OPT) + theState = {} + if path.isfile(stateFile): + logger.debug("Loading export options file") + try: + with open(stateFile,mode="r") as inFile: + theJson = inFile.read() + theState = json.loads(theJson) + except Exception as e: + logger.error("Failed to load export options file") + logger.error(str(e)) + return False + for anOpt in theState: + self.theState[anOpt] = theState[anOpt] + return True + + def saveSettings(self): + stateFile = path.join(self.theProject.projMeta, nwFiles.EXPORT_OPT) + logger.debug("Saving export options file") + try: + with open(stateFile,mode="w+") as outFile: + outFile.write(json.dumps(self.theState, indent=2)) + except Exception as e: + logger.error("Failed to save export options file") + logger.error(str(e)) + return False + return True + + def setSetting(self, setName, setValue): + if setName in self.theState: + self.theState[setName] = setValue + else: + return False + return True + + def getSetting(self, setName): + if setName in self.stringOpt: + return checkString(self.theState[setName],self.theState[setName],False) + elif setName in self.boolOpt: + return checkBool(self.theState[setName],self.theState[setName],False) + elif setName in self.intOpt: + return checkInt(self.theState[setName],self.theState[setName],False) + return None + +# END Class ExportLastState diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index c319dc20..a5d77820 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -192,6 +192,13 @@ class GuiMainMenu(QMenuBar): menuItem.triggered.connect(self.theParent.editProjectDialog) self.projMenu.addAction(menuItem) + # Project > Export Project + menuItem = QAction("Export Project", self) + menuItem.setStatusTip("Export project") + menuItem.setShortcut("F5") + menuItem.triggered.connect(self.theParent.exportProjectDialog) + self.projMenu.addAction(menuItem) + # Project > Separator self.projMenu.addSeparator() diff --git a/nw/gui/winmain.py b/nw/gui/winmain.py index 8dee5c41..7d3d0aa8 100644 --- a/nw/gui/winmain.py +++ b/nw/gui/winmain.py @@ -30,6 +30,7 @@ from nw.gui.searchbar import GuiSearchBar from nw.gui.mainmenu import GuiMainMenu from nw.gui.configeditor import GuiConfigEditor from nw.gui.projecteditor import GuiProjectEditor +from nw.gui.export import GuiExport from nw.gui.itemeditor import GuiItemEditor from nw.gui.statusbar import GuiMainStatus from nw.gui.timelineview import GuiTimeLineView @@ -511,6 +512,12 @@ class GuiMain(QMainWindow): self._setWindowTitle(self.theProject.projName) return True + def exportProjectDialog(self): + if self.hasProject: + dlgExport = GuiExport(self, self.theProject) + dlgExport.exec_() + return True + def showTimeLineDialog(self): if self.hasProject: dlgTLine = GuiTimeLineView(self, self.theProject, self.theIndex) diff --git a/nw/tools/translate.py b/nw/tools/translate.py new file mode 100644 index 00000000..c32db147 --- /dev/null +++ b/nw/tools/translate.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""novelWriter Translate Tools + + novelWriter – Translate Tools +=============================== + Various translate tools + + File History: + Created: 2019-10-13 [0.2.3] + +""" + +import logging +import nw + +from os import path + +logger = logging.getLogger(__name__) + +def numberToWord(numVal, theLanguage): + numWord = "" + if theLanguage == "en": + numWord = _numberToWordEN(numVal) + else: + numWord = _numberToWordEN(numVal) + # print("%4d : %s" % (numVal, numWord)) + return numWord + +def _numberToWordEN(numVal): + + numWord = "" + oneWord = "" + tenWord = "" + hunWord = "" + + if numVal == 0: + return "Zero" + + oneVal = numVal % 10 + tenVal = (numVal-oneVal) % 100 + hunVal = (numVal-tenVal-oneVal) % 1000 + + if hunVal == 100: hunWord = "One Hundred" + if hunVal == 200: hunWord = "Two Hundred" + if hunVal == 300: hunWord = "Three Hundred" + if hunVal == 400: hunWord = "Four Hundred" + if hunVal == 500: hunWord = "Five Hundred" + if hunVal == 600: hunWord = "Six Hundred" + if hunVal == 700: hunWord = "Seven Hundred" + if hunVal == 800: hunWord = "Eight Hundred" + if hunVal == 900: hunWord = "Nine Hundred" + + if tenVal == 20: tenWord = "Twenty" + if tenVal == 30: tenWord = "Thirty" + if tenVal == 40: tenWord = "Forty" + if tenVal == 50: tenWord = "Fifty" + if tenVal == 60: tenWord = "Sixty" + if tenVal == 70: tenWord = "Seventy" + if tenVal == 80: tenWord = "Eighty" + if tenVal == 90: tenWord = "Ninety" + + if tenVal == 10: + if oneVal == 0: oneWord = "Ten" + if oneVal == 1: oneWord = "Eleven" + if oneVal == 2: oneWord = "Twelve" + if oneVal == 3: oneWord = "Thirteen" + if oneVal == 4: oneWord = "Fourteen" + if oneVal == 5: oneWord = "Fifteen" + if oneVal == 6: oneWord = "Sixteen" + if oneVal == 7: oneWord = "Seventeen" + if oneVal == 8: oneWord = "Eighteen" + if oneVal == 9: oneWord = "Nineteen" + numWord = ("%s %s" % (hunWord, oneWord)).strip() + else: + if oneVal == 0: oneWord = "" + if oneVal == 1: oneWord = "One" + if oneVal == 2: oneWord = "Two" + if oneVal == 3: oneWord = "Three" + if oneVal == 4: oneWord = "Four" + if oneVal == 5: oneWord = "Five" + if oneVal == 6: oneWord = "Six" + if oneVal == 7: oneWord = "Seven" + if oneVal == 8: oneWord = "Eight" + if oneVal == 9: oneWord = "Nine" + if tenVal == 0: + numWord = ("%s %s" % (hunWord, oneWord)).strip() + else: + if oneVal == 0: + numWord = ("%s %s" % (hunWord, tenWord)).strip() + else: + numWord = ("%s %s-%s" % (hunWord, tenWord, oneWord)).strip() + + return numWord diff --git a/sample/sampleNovel/data_6/36b6aa9b697b_main.nwd b/sample/sampleNovel/data_6/36b6aa9b697b_main.nwd index e4c4beb8..0a53e976 100644 --- a/sample/sampleNovel/data_6/36b6aa9b697b_main.nwd +++ b/sample/sampleNovel/data_6/36b6aa9b697b_main.nwd @@ -1,6 +1,4 @@ -# This is the Title - -## This is the Subtitle +### Making a Scene % Begin Meta @pov: Jane diff --git a/sample/sampleNovel/data_6/a2d6d5f4f401_main.nwd b/sample/sampleNovel/data_6/a2d6d5f4f401_main.nwd new file mode 100644 index 00000000..c522a4da --- /dev/null +++ b/sample/sampleNovel/data_6/a2d6d5f4f401_main.nwd @@ -0,0 +1,5 @@ +## So it Begins + +@pov: Jane + +% The first chapter with much action and such. \ No newline at end of file diff --git a/sample/sampleNovel/data_8/8706ddc78b1b_main.nwd b/sample/sampleNovel/data_8/8706ddc78b1b_main.nwd new file mode 100644 index 00000000..e8d20166 --- /dev/null +++ b/sample/sampleNovel/data_8/8706ddc78b1b_main.nwd @@ -0,0 +1,5 @@ +## Where has John Gone? + +@pov: Jane + +% We continue the saga of John and Jane \ No newline at end of file diff --git a/sample/sampleNovel/data_a/e7339df26ded_main.nwd b/sample/sampleNovel/data_a/e7339df26ded_main.nwd new file mode 100644 index 00000000..71020309 --- /dev/null +++ b/sample/sampleNovel/data_a/e7339df26ded_main.nwd @@ -0,0 +1,23 @@ +### We Found John! + +@pov: John + +Jane has been searching for a while, and she finally found John on Mars. He was indeed in space! What was he doing on Mars anyway? Well, it turns out, he was farming potatoes. + +Farming potatoes you say? Why would you do that? + +No one knows, but it seemed like a good idea at the time I suppose. + +### A Note on Potato Farming on Mars + +@pov: John + +% We’re adding a second scene to the same file here, which is perfectly fine I may add. + +Potatoes cannot be farmed on Mars. That is simply a fact. There is no soil. Unless John brings the soil himself. Wait, did he? + +I don’t want to know. + +#### Conclusion + +Why would anyone write about such things? let alone make a film about it? \ No newline at end of file diff --git a/sample/sampleNovel/data_b/c0cbd2a407f3_main.nwd b/sample/sampleNovel/data_b/c0cbd2a407f3_main.nwd index 0a39ca10..525f47c8 100644 --- a/sample/sampleNovel/data_b/c0cbd2a407f3_main.nwd +++ b/sample/sampleNovel/data_b/c0cbd2a407f3_main.nwd @@ -1,6 +1,8 @@ -# This is a New File! +### Another Scene @pov: John @location: Space -Although, not so new now that it has text in it an everything … \ No newline at end of file +This is the second scene in out story. We have no idea what’s going on, so we’re just going to ramble on until we have a few lines of text so that the editor has something to work with. + +In fact, this scene is supposed to be about John, but we don’t really know anything about John, except that he is somewhere in space. Perhaps he’s lost? Or has gone where no man has gone before? Where is Jane then? \ No newline at end of file diff --git a/sample/sampleNovel/nwProject.nwx b/sample/sampleNovel/nwProject.nwx index e0347637..dba1732b 100644 --- a/sample/sampleNovel/nwProject.nwx +++ b/sample/sampleNovel/nwProject.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -11,7 +11,7 @@ True 636b6aa9b697b 636b6aa9b697b - 581 + 758 B E @@ -33,7 +33,7 @@ Main - + Novel ROOT @@ -51,7 +51,7 @@ 23 5 1 - 0 + 16 Some Chapter @@ -60,41 +60,77 @@ Notes True - - New Scene + + Chapter One + FILE + NOVEL + New + False + CHAPTER + 12 + 3 + 0 + 75 + + + Making a Scene + FILE + NOVEL + Notes + False + SCENE + 713 + 132 + 6 + 85 + + + Another Scene + FILE + NOVEL + Notes + False + SCENE + 412 + 82 + 2 + 448 + + + A Note on Ipsums FILE NOVEL Started False - SCENE + NOTE 2571 377 5 0 - - File With Stuff + + Chapter Two FILE NOVEL - Notes + New False - SCENE - 736 - 137 - 6 - 82 + CHAPTER + 20 + 4 + 0 + 76 - - New File + + We Found John! FILE NOVEL - Notes + New False SCENE - 82 - 19 - 1 - 69 + 567 + 112 + 6 + 634 Characters