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