From 5f4b6009ce1a88939520c0a3e50896944fb43a65 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 9 Jun 2023 21:07:46 +0200 Subject: [PATCH] Complete the manuscript build dialog, and the doc builder class --- novelwriter/core/docbuild.py | 28 ++-- novelwriter/core/tohtml.py | 53 +++++--- novelwriter/core/tokenizer.py | 41 ++++-- novelwriter/core/tomd.py | 1 + novelwriter/core/toodt.py | 3 + novelwriter/extensions/circularprogress.py | 1 + novelwriter/extensions/simpleprogress.py | 54 ++++++++ novelwriter/tools/manusbuild.py | 122 ++++++++++++------ .../mBuildDocBuild_HTML5_Lorem_Ipsum.htm | 1 - tests/test_core/test_core_tohtml.py | 3 +- 10 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 novelwriter/extensions/simpleprogress.py diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index de3ab546..6601496f 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -32,7 +32,7 @@ from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG from novelwriter.enum import nwBuildFmt -from novelwriter.error import formatException +from novelwriter.error import formatException, logException from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml @@ -110,13 +110,13 @@ class NWBuildDocument: def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]: """Wrapper for builders based on format.""" if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT): - yield from self.iterBuildOpenDocument(path, bFormat == "fodt") + yield from self.iterBuildOpenDocument(path, bFormat == nwBuildFmt.FODT) elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML): - yield from self.iterBuildHTML(path if bFormat == "html" else None) + yield from self.iterBuildHTML(path, asJson=bFormat == nwBuildFmt.J_HTML) elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD): - yield from self.iterBuildMarkdown(path, bFormat == "md+") + yield from self.iterBuildMarkdown(path, bFormat == nwBuildFmt.EXT_MD) elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD): - yield from self.iterBuildNovelWriter(path if bFormat == "nwd" else None) + yield from self.iterBuildNWD(path, asJson=bFormat == nwBuildFmt.J_NWD) return def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: @@ -143,11 +143,12 @@ class NWBuildDocument: else: makeObj.saveOpenDocText(path) except Exception as exc: + logException() self._error = formatException(exc) return - def iterBuildHTML(self, path: Path | None) -> Iterable[tuple[int, bool]]: + def iterBuildHTML(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]: """Build an HTML file. If path is None, no file is saved. This is used for generating build previews. """ @@ -169,8 +170,12 @@ class NWBuildDocument: if isinstance(path, Path): try: - makeObj.saveHTML5(path) + if asJson: + makeObj.saveHtmlJson(path) + else: + makeObj.saveHtml5(path) except Exception as exc: + logException() self._error = formatException(exc) return @@ -201,11 +206,12 @@ class NWBuildDocument: try: makeObj.saveMarkdown(path) except Exception as exc: + logException() self._error = formatException(exc) return - def iterBuildNovelWriter(self, path: Path | None) -> Iterable[tuple[int, bool]]: + def iterBuildNWD(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]: """Build a novelWriter Markdown file.""" makeObj = ToMarkdown(self._project) filtered = self._setupBuild(makeObj) @@ -226,8 +232,12 @@ class NWBuildDocument: if isinstance(path, Path): try: - makeObj.saveRawMarkdown(path) + if asJson: + makeObj.saveRawMarkdownJSON(path) + else: + makeObj.saveRawMarkdown(path) except Exception as exc: + logException() self._error = formatException(exc) return diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index f02daafb..1441e1d3 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -23,11 +23,14 @@ along with this program. If not, see . """ from __future__ import annotations +import json import logging +from time import time from pathlib import Path from novelwriter import CONFIG +from novelwriter.common import formatTimeStamp from novelwriter.constants import nwKeyWords, nwLabels, nwHtmlUnicode from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape @@ -296,38 +299,52 @@ class ToHtml(Tokenizer): return - def saveHTML5(self, savePath: str | Path): - """Save the data to an .html file. - """ - with open(savePath, mode="w", encoding="utf-8") as outFile: - theStyle = self.getStyleSheet() - theStyle.append("article {width: 800px; margin: 40px auto;}") - bodyText = "".join(self._fullHTML) - bodyText = bodyText.replace("\t", " ").rstrip() - - theHtml = ( + def saveHtml5(self, path: str | Path): + """Save the data to an HTML file.""" + with open(path, mode="w", encoding="utf-8") as fObj: + fObj.write(( "\n" "\n" "\n" "\n" - "{projTitle:s}\n" + "{title:s}\n" "\n" "\n" "\n" "
\n" - "{bodyText:s}\n" + "{body:s}\n" "
\n" "\n" "\n" ).format( - projTitle=self._project.data.name, - htmlStyle="\n".join(theStyle), - bodyText=bodyText, - ) - outFile.write(theHtml) + title=self._project.data.name, + style="\n".join(self.getStyleSheet()), + body=("".join(self._fullHTML)).replace("\t", " ").rstrip(), + )) + logger.info("Wrote file: %s", path) + return + def saveHtmlJson(self, path: str | Path): + """Save the data to a JSON file.""" + timeStamp = time() + data = { + "meta": { + "projectName": self._project.data.name, + "novelTitle": self._project.data.title, + "novelAuthor": self._project.data.author, + "buildTime": int(timeStamp), + "buildTimeStr": formatTimeStamp(timeStamp), + }, + "text": { + "css": self.getStyleSheet(), + "html": [page.rstrip("\n").split("\n") for page in self.fullHTML], + } + } + with open(path, mode="w", encoding="utf-8") as fObj: + json.dump(data, fObj, indent=2) + logger.info("Wrote file: %s", path) return def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 62a6a71f..e1a9aea4 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -25,9 +25,11 @@ along with this program. If not, see . from __future__ import annotations import re +import json import logging from abc import ABC, abstractmethod +from time import time from pathlib import Path from operator import itemgetter from functools import partial @@ -35,7 +37,7 @@ from functools import partial from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter.enum import nwItemLayout, nwItemType -from novelwriter.common import numberToRoman, checkInt +from novelwriter.common import formatTimeStamp, numberToRoman, checkInt from novelwriter.constants import nwConst, nwHeadFmt, nwRegEx, nwUnicode from novelwriter.core.project import NWProject @@ -740,13 +742,32 @@ class Tokenizer(ABC): return True - def saveRawMarkdown(self, savePath: str | Path): - """Save the data to a plain text file.""" - with open(savePath, mode="w", encoding="utf-8") as outFile: + def saveRawMarkdown(self, path: str | Path): + """Save the raw text to a plain text file.""" + with open(path, mode="w", encoding="utf-8") as outFile: for nwdPage in self._allMarkdown: outFile.write(nwdPage) return + def saveRawMarkdownJSON(self, path: str | Path): + """Save the raw text to a JSON file.""" + timeStamp = time() + data = { + "meta": { + "projectName": self._project.data.name, + "novelTitle": self._project.data.title, + "novelAuthor": self._project.data.author, + "buildTime": int(timeStamp), + "buildTimeStr": formatTimeStamp(timeStamp), + }, + "text": { + "nwd": [page.rstrip("\n").split("\n") for page in self._allMarkdown], + } + } + with open(path, mode="w", encoding="utf-8") as fObj: + json.dump(data, fObj, indent=2) + return + # END Class Tokenizer @@ -760,27 +781,23 @@ class HeadingFormatter: return def incChapter(self): - """Increment the chapter counter. - """ + """Increment the chapter counter.""" self._chCount += 1 return def incScene(self): - """Increment the scene counters. - """ + """Increment the scene counters.""" self._scChCount += 1 self._scAbsCount += 1 return def resetScene(self): - """Reset the chapter scene counter. - """ + """Reset the chapter scene counter.""" self._scChCount = 0 return def apply(self, hFormat: str, text: str): - """Apply formatting to a specific heading. - """ + """Apply formatting to a specific heading.""" hFormat = hFormat.replace(nwHeadFmt.TITLE, text) hFormat = hFormat.replace(nwHeadFmt.CH_NUM, str(self._chCount)) hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scChCount)) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 8e05c40b..d6572ccd 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -179,6 +179,7 @@ class ToMarkdown(Tokenizer): """Save the data to a plain text file.""" with open(path, mode="w", encoding="utf-8") as outFile: outFile.write("".join(self._fullMD)) + logger.info("Wrote file: %s", path) return def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 888e4e42..3bdb66b5 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -501,6 +501,7 @@ class ToOdt(Tokenizer): xml = ET.ElementTree(self._dFlat) xmlIndent(xml) xml.write(fObj, encoding="utf-8", xml_declaration=True) + logger.info("Wrote file: %s", path) return def saveOpenDocText(self, path: str | Path): @@ -535,6 +536,8 @@ class ToOdt(Tokenizer): putInZip("meta.xml", self._dMeta, outZip) putInZip("styles.xml", self._dStyl, outZip) + logger.info("Wrote file: %s", path) + return ## diff --git a/novelwriter/extensions/circularprogress.py b/novelwriter/extensions/circularprogress.py index dfaf586b..79dacfd1 100644 --- a/novelwriter/extensions/circularprogress.py +++ b/novelwriter/extensions/circularprogress.py @@ -21,6 +21,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations from math import ceil diff --git a/novelwriter/extensions/simpleprogress.py b/novelwriter/extensions/simpleprogress.py new file mode 100644 index 00000000..2dbf4e43 --- /dev/null +++ b/novelwriter/extensions/simpleprogress.py @@ -0,0 +1,54 @@ +""" +novelWriter – Custom Widget: Progress Simple +============================================ + +File History: +Created: 2023-06-09 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from math import ceil + +from PyQt5.QtGui import QPaintEvent, QPainter +from PyQt5.QtWidgets import QProgressBar, QWidget + + +class NProgressSimple(QProgressBar): + """Extension: Simple Progress Widget + + A custom widget that paints a plain bar with no other styling. + """ + + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + return + + def paintEvent(self, event: QPaintEvent): + """Custom painter for the progress bar.""" + if self.value() == 0: + return + progress = ceil(self.width()*float(self.value())/self.maximum()) + qPaint = QPainter(self) + qPaint.setRenderHint(QPainter.Antialiasing, True) + qPaint.setPen(self.palette().highlight().color()) + qPaint.setBrush(self.palette().highlight()) + qPaint.drawRect(0, 0, progress, self.height()) + return + +# END Class NProgressSimple diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index f00f3bd0..3b5d6271 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -24,24 +24,25 @@ along with this program. If not, see . from __future__ import annotations import logging -from pathlib import Path from typing import TYPE_CHECKING +from pathlib import Path -from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QProgressBar, QPushButton, QSplitter, QVBoxLayout, QWidget + QPushButton, QSplitter, QVBoxLayout, QWidget ) from novelwriter import CONFIG -from novelwriter.enum import nwBuildFmt +from novelwriter.enum import nwAlert, nwBuildFmt from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels from novelwriter.core.item import NWItem from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildSettings +from novelwriter.extensions.simpleprogress import NProgressSimple if TYPE_CHECKING: from novelwriter.guimain import GuiMain @@ -50,7 +51,7 @@ logger = logging.getLogger(__name__) class GuiManuscriptBuild(QDialog): - """GUI Tools: Manucript Builder Dialog + """GUI Tools: Manuscript Build Dialog This is the tool for running the build itself. It can be accessed independently of the Manuscript Build Tool. @@ -73,10 +74,13 @@ class GuiManuscriptBuild(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(500)) - self.setMinimumHeight(CONFIG.pxInt(250)) + self.setMinimumHeight(CONFIG.pxInt(300)) iPx = self.mainTheme.baseIconSize - wWin = CONFIG.pxInt(660) + sp4 = CONFIG.pxInt(4) + sp8 = CONFIG.pxInt(8) + sp16 = CONFIG.pxInt(16) + wWin = CONFIG.pxInt(620) hWin = CONFIG.pxInt(360) pOptions = self.theProject.options @@ -141,35 +145,42 @@ class GuiManuscriptBuild(QDialog): self.lblMain.setWordWrap(True) self.lblMain.setFont(font) + # Build Path + self.lblPath = QLabel(self.tr("Path")) + self.buildPath = QLineEdit(self) + self.btnBrowse = QPushButton(self.mainTheme.getIcon("browse"), "") + + self.pathBox = QHBoxLayout() + self.pathBox.addWidget(self.buildPath) + self.pathBox.addWidget(self.btnBrowse) + self.pathBox.setSpacing(sp8) + # Build Name self.lblName = QLabel(self.tr("File Name")) - self.buildName = QLineEdit() + self.buildName = QLineEdit(self) self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") self.btnReset.setToolTip(self.tr("Reset file name to default")) self.nameBox = QHBoxLayout() self.nameBox.addWidget(self.buildName) self.nameBox.addWidget(self.btnReset) + self.nameBox.setSpacing(sp8) # Build Progress - self.lblProgress = QLabel(self.tr("Progress")) - - self.buildProgress = QProgressBar() + self.buildProgress = NProgressSimple(self) self.buildProgress.setMinimum(0) self.buildProgress.setValue(0) - - self.progressBox = QVBoxLayout() - self.progressBox.addWidget(self.lblProgress) - self.progressBox.addWidget(self.buildProgress) - self.progressBox.setSpacing(CONFIG.pxInt(4)) + self.buildProgress.setTextVisible(False) + self.buildProgress.setFixedHeight(sp8) # Build Box self.buildBox = QGridLayout() - self.buildBox.addWidget(self.lblName, 1, 0) - self.buildBox.addLayout(self.nameBox, 1, 1) - self.buildBox.addWidget(self.lblProgress, 2, 0) - self.buildBox.addWidget(self.buildProgress, 2, 1) - self.buildBox.setVerticalSpacing(CONFIG.pxInt(4)) + self.buildBox.addWidget(self.lblPath, 0, 0) + self.buildBox.addLayout(self.pathBox, 0, 1) + self.buildBox.addWidget(self.lblName, 1, 0) + self.buildBox.addLayout(self.nameBox, 1, 1) + self.buildBox.setHorizontalSpacing(sp8) + self.buildBox.setVerticalSpacing(sp4) # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) @@ -182,7 +193,7 @@ class GuiManuscriptBuild(QDialog): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.formatWidget) self.mainSplit.addWidget(self.contentWidget) - self.mainSplit.setHandleWidth(CONFIG.pxInt(16)) + self.mainSplit.setHandleWidth(sp16) self.mainSplit.setCollapsible(0, False) self.mainSplit.setCollapsible(1, False) self.mainSplit.setSizes([ @@ -192,15 +203,21 @@ class GuiManuscriptBuild(QDialog): self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.lblMain, 0, Qt.AlignCenter) + self.outerBox.addSpacing(sp16) self.outerBox.addWidget(self.mainSplit, 1) + self.outerBox.addSpacing(sp4) + self.outerBox.addWidget(self.buildProgress, 0) + self.outerBox.addSpacing(sp4) self.outerBox.addLayout(self.buildBox, 0) + self.outerBox.addSpacing(sp16) self.outerBox.addWidget(self.dlgButtons, 0) - self.outerBox.setSpacing(CONFIG.pxInt(12)) + self.outerBox.setSpacing(0) self.setLayout(self.outerBox) self.btnBuild.setFocus() self._populateContentList() + self.buildPath.setText(str(self._build.lastPath)) if self._build.lastBuildName: self.buildName.setText(self._build.lastBuildName) else: @@ -208,6 +225,7 @@ class GuiManuscriptBuild(QDialog): # Signals self.btnReset.clicked.connect(self._doResetBuildName) + self.btnBrowse.clicked.connect(self._doSelectPath) self.dlgButtons.clicked.connect(self._dialogButtonClicked) self.listFormats.itemSelectionChanged.connect(self._formatSelectionChanged) @@ -253,6 +271,18 @@ class GuiManuscriptBuild(QDialog): self.close() return + @pyqtSlot() + def _doSelectPath(self): + """Select a folder for output.""" + bPath = Path(self.buildPath.text()) + bPath = bPath if bPath.is_dir() else self._build.lastPath + savePath = QFileDialog.getExistingDirectory( + self, self.tr("Select Folder"), str(bPath) + ) + if savePath: + self.buildPath.setText(savePath) + return + @pyqtSlot() def _doResetBuildName(self): """Generate a default build name.""" @@ -269,43 +299,55 @@ class GuiManuscriptBuild(QDialog): self.buildProgress.setValue(0) return + @pyqtSlot() + def _resetProgress(self): + """Set the progress bar back to 0.""" + self.buildProgress.setValue(0) + return + ## # Internal Functions ## def _runBuild(self) -> bool: """Run the currently selected build.""" - selFormat = self._getSelectedFormat() - if not isinstance(selFormat, nwBuildFmt): + bFormat = self._getSelectedFormat() + if not isinstance(bFormat, nwBuildFmt): return False - lastName = self.buildName.text().strip() - if not lastName: + bName = self.buildName.text().strip() + if not bName: self._doResetBuildName() - - lastPath = self._build.lastPath - selExt = nwLabels.BUILD_EXT[selFormat] - selName = Path(makeFileNameSafe(lastName)).with_suffix(selExt) + bName = self.buildName.text().strip() self.buildProgress.setValue(0) - savePath, _ = QFileDialog.getSaveFileName( - self, self.tr("Save Manuscript As"), str(lastPath / selName) - ) - if not savePath: + bPath = Path(self.buildPath.text()) + if not bPath.is_dir(): + self.mainGui.makeAlert(self.tr("Output folder does not exist."), nwAlert.ERROR) return False - buildPath = Path(savePath) + bExt = nwLabels.BUILD_EXT[bFormat] + buildPath = (bPath / makeFileNameSafe(bName)).with_suffix(bExt) + + if buildPath.exists(): + if not self.mainGui.askQuestion( + self.tr("File Exists"), + self.tr("The file already exists. Do you want to overwrite it?") + ): + return False docBuild = NWBuildDocument(self.theProject, self._build) docBuild.queueAll() self.buildProgress.setMaximum(len(docBuild)) - for i, _ in docBuild.iterBuild(buildPath, selFormat): + for i, _ in docBuild.iterBuild(buildPath, bFormat): self.buildProgress.setValue(i+1) - self._build.setLastFormat(selFormat) - self._build.setLastPath(buildPath.parent) - self._build.setLastBuildName(lastName) + self._build.setLastPath(bPath) + self._build.setLastBuildName(bName) + self._build.setLastFormat(bFormat) + + QTimer.singleShot(1000, self._resetProgress) return True diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm index dcea6271..3d505118 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm @@ -18,7 +18,6 @@ a {color: rgb(66, 113, 174);} .break {text-align: left;} .synopsis {font-style: italic;} .comment {font-style: italic; color: rgb(100, 100, 100);} -article {width: 800px; margin: 40px auto;}
diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index e0a0d1b3..ebaf1860 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -517,7 +517,6 @@ def testCoreToHtml_Complex(mockGUI, fncPath): # ========== theStyle = theHtml.getStyleSheet() - theStyle.append("article {width: 800px; margin: 40px auto;}") htmlDoc = ( "\n" "\n" @@ -540,7 +539,7 @@ def testCoreToHtml_Complex(mockGUI, fncPath): ) saveFile = fncPath / "outFile.htm" - theHtml.saveHTML5(saveFile) + theHtml.saveHtml5(saveFile) assert readFile(saveFile) == htmlDoc # END Test testCoreToHtml_Complex