diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 9a871e4d..ebaef80c 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -99,7 +99,7 @@ class nwHeaders: H_VALID = ("H0", "H1", "H2", "H3", "H4") H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4} - H_SIZES = {0: 1.00, 1: 2.00, 2: 1.75, 3: 1.50, 4: 1.25} + H_SIZES = {0: 2.50, 1: 2.00, 2: 1.75, 3: 1.50, 4: 1.25} class nwFiles: diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 1b5350a8..9c17e73e 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -39,6 +39,7 @@ from novelwriter.core.tohtml import ToHtml from novelwriter.core.tokenizer import Tokenizer from novelwriter.core.tomarkdown import ToMarkdown from novelwriter.core.toodt import ToOdt +from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument from novelwriter.enum import nwBuildFmt from novelwriter.error import formatException, logException @@ -134,6 +135,30 @@ class NWBuildDocument: self._queue.append(item.itemHandle) return + def iterBuildPreview(self, theme: TextDocumentTheme) -> Iterable[tuple[int, bool]]: + """Build a preview QTextDocument.""" + makeObj = ToQTextDocument(self._project) + filtered = self._setupBuild(makeObj) + + font = QFont() + font.fromString(self._build.getStr("format.textFont")) + + makeObj.setLinkHeadings(self._preview) + makeObj.initDocument(font, theme) + for i, tHandle in enumerate(self._queue): + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False + + makeObj.appendFootnotes() + + self._error = None + self._cache = makeObj + + return + def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]: """Wrapper for builders based on format.""" if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT): diff --git a/novelwriter/core/toqdoc.py b/novelwriter/core/toqdoc.py index 0e641168..bfe4aa52 100644 --- a/novelwriter/core/toqdoc.py +++ b/novelwriter/core/toqdoc.py @@ -26,18 +26,17 @@ from __future__ import annotations import logging from PyQt5.QtGui import ( - QFont, QFontMetrics, QTextBlockFormat, QTextCharFormat, QTextCursor, - QTextDocument + QColor, QFont, QFontMetrics, QTextBlockFormat, QTextCharFormat, + QTextCursor, QTextDocument ) -from novelwriter import SHARED from novelwriter.constants import nwHeaders, nwHeadFmt, nwKeyWords, nwLabels, nwUnicode from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import T_Formats, Tokenizer from novelwriter.types import ( - QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight, QtPageBreakAfter, - QtPageBreakBefore, QtTransparent, QtVAlignNormal, QtVAlignSub, - QtVAlignSuper + QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight, QtBlack, + QtPageBreakAfter, QtPageBreakBefore, QtTransparent, QtVAlignNormal, + QtVAlignSub, QtVAlignSuper ) logger = logging.getLogger(__name__) @@ -45,6 +44,19 @@ logger = logging.getLogger(__name__) T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat] +class TextDocumentTheme: + text: QColor = QtBlack + highlight: QColor = QtTransparent + head: QColor = QtBlack + comment: QColor = QtBlack + note: QColor = QtBlack + code: QColor = QtBlack + modifier: QColor = QtBlack + keyword: QColor = QtBlack + tag: QColor = QtBlack + optional: QColor = QtBlack + + def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None: if cursor.position() > 0: cursor.insertBlock(bFmt) @@ -64,6 +76,7 @@ class ToQTextDocument(Tokenizer): self._document = QTextDocument() self._document.setUndoRedoEnabled(False) + self._theme = TextDocumentTheme() self._styles: dict[int, T_TextStyle] = {} self._usedNotes: dict[str, int] = {} @@ -73,9 +86,10 @@ class ToQTextDocument(Tokenizer): return - def initDocument(self, font: QFont) -> None: + def initDocument(self, font: QFont, theme: TextDocumentTheme) -> None: """Initialise all computed values of the document.""" self._textFont = font + self._theme = theme self._document.setUndoRedoEnabled(False) self._document.blockSignals(True) @@ -95,7 +109,7 @@ class ToQTextDocument(Tokenizer): } self._sHead = { - self.T_TITLE: nwHeaders.H_SIZES.get(1, 1.0) * fPt, + self.T_TITLE: nwHeaders.H_SIZES.get(0, 1.0) * fPt, self.T_HEAD1: nwHeaders.H_SIZES.get(1, 1.0) * fPt, self.T_HEAD2: nwHeaders.H_SIZES.get(2, 1.0) * fPt, self.T_HEAD3: nwHeaders.H_SIZES.get(3, 1.0) * fPt, @@ -107,33 +121,41 @@ class ToQTextDocument(Tokenizer): self._mIndent = mScale * 2.0 - self._defaultChar = QTextCharFormat() - self._defaultChar.setForeground(SHARED.theme.colText) + self._cText = QTextCharFormat() + self._cText.setForeground(self._theme.text) - self._comChar = QTextCharFormat() - self._comChar.setForeground(SHARED.theme.colHidden) + self._cHead = QTextCharFormat() + self._cHead.setForeground(self._theme.head) - self._noteChar = QTextCharFormat() - self._noteChar.setForeground(SHARED.theme.colNote) + self._cComment = QTextCharFormat() + self._cComment.setForeground(self._theme.comment) - self._codeChar = QTextCharFormat() - self._codeChar.setForeground(SHARED.theme.colCode) + self._cCommentMod = QTextCharFormat() + self._cCommentMod.setForeground(self._theme.comment) + self._cCommentMod.setFontWeight(self._bold) - self._modChar = QTextCharFormat() - self._modChar.setForeground(SHARED.theme.colMod) + self._cNote = QTextCharFormat() + self._cNote.setForeground(self._theme.note) - self._keyChar = QTextCharFormat() - self._keyChar.setForeground(SHARED.theme.colKey) + self._cCode = QTextCharFormat() + self._cCode.setForeground(self._theme.code) - self._tagChar = QTextCharFormat() - self._tagChar.setForeground(SHARED.theme.colTag) + self._cModifier = QTextCharFormat() + self._cModifier.setForeground(self._theme.modifier) + self._cModifier.setFontWeight(self._bold) - self._optChar = QTextCharFormat() - self._optChar.setForeground(SHARED.theme.colOpt) + self._cKeyword = QTextCharFormat() + self._cKeyword.setForeground(self._theme.keyword) - self._defaultBlock = QTextBlockFormat() - self._defaultBlock.setTopMargin(self._mText[0]) - self._defaultBlock.setBottomMargin(self._mText[1]) + self._cTag = QTextCharFormat() + self._cTag.setForeground(self._theme.tag) + + self._cOptional = QTextCharFormat() + self._cOptional.setForeground(self._theme.optional) + + self._blockFmt = QTextBlockFormat() + self._blockFmt.setTopMargin(self._mText[0]) + self._blockFmt.setBottomMargin(self._mText[1]) self._init = True @@ -159,11 +181,12 @@ class ToQTextDocument(Tokenizer): self._document.blockSignals(True) cursor = QTextCursor(self._document) + cursor.movePosition(QTextCursor.MoveOperation.End) for tType, nHead, tText, tFormat, tStyle in self._tokens: # Styles - bFmt = QTextBlockFormat(self._defaultBlock) + bFmt = QTextBlockFormat(self._blockFmt) if tStyle is not None: if tStyle & self.A_LEFT: bFmt.setAlignment(QtAlignLeft) @@ -191,7 +214,7 @@ class ToQTextDocument(Tokenizer): if tType == self.T_TEXT: newBlock(cursor, bFmt) - self._insertFragments(tText, tFormat, cursor, self._defaultChar) + self._insertFragments(tText, tFormat, cursor, self._cText) elif tType in self.L_HEADINGS: bFmt, cFmt = self._genHeadStyle(tType, nHead, bFmt) @@ -200,23 +223,25 @@ class ToQTextDocument(Tokenizer): elif tType == self.T_SEP: newBlock(cursor, bFmt) - cursor.insertText(tText, self._defaultChar) + cursor.insertText(tText, self._cText) elif tType == self.T_SKIP: newBlock(cursor, bFmt) - cursor.insertText(nwUnicode.U_NBSP, self._defaultChar) + cursor.insertText(nwUnicode.U_NBSP, self._cText) elif tType in self.L_SUMMARY and self._doSynopsis: newBlock(cursor, bFmt) - prefix = self._localLookup( + modifier = self._localLookup( "Short Description" if tType == self.T_SHORT else "Synopsis" ) - cursor.insertText(f"{prefix}: ", self._modChar) - self._insertFragments(tText, tFormat, cursor, self._noteChar) + cursor.insertText(f"{modifier}: ", self._cModifier) + self._insertFragments(tText, tFormat, cursor, self._cNote) elif tType == self.T_COMMENT and self._doComments: newBlock(cursor, bFmt) - self._insertFragments(tText, tFormat, cursor, self._comChar) + modifier = self._localLookup("Comment") + cursor.insertText(f"{modifier}: ", self._cCommentMod) + self._insertFragments(tText, tFormat, cursor, self._cComment) elif tType == self.T_KEYWORD and self._doKeywords: newBlock(cursor, bFmt) @@ -234,18 +259,18 @@ class ToQTextDocument(Tokenizer): cursor = QTextCursor(self._document) cursor.movePosition(QTextCursor.MoveOperation.End) - bFmt, cFmt = self._genHeadStyle(self.T_HEAD3, -1, self._defaultBlock) + bFmt, cFmt = self._genHeadStyle(self.T_HEAD3, -1, self._blockFmt) newBlock(cursor, bFmt) cursor.insertText(self._localLookup("Footnotes"), cFmt) for key, index in self._usedNotes.items(): if content := self._footnotes.get(key): - cFmt = QTextCharFormat(self._codeChar) + cFmt = QTextCharFormat(self._cCode) cFmt.setAnchor(True) cFmt.setAnchorNames([f"footnote_{index}"]) - newBlock(cursor, self._defaultBlock) + newBlock(cursor, self._blockFmt) cursor.insertText(f"{index}. ", cFmt) - self._insertFragments(*content, cursor, self._defaultChar) + self._insertFragments(*content, cursor, self._cText) self._document.blockSignals(False) @@ -285,7 +310,7 @@ class ToQTextDocument(Tokenizer): elif fmt == self.FMT_U_E: cFmt.setFontUnderline(False) elif fmt == self.FMT_M_B: - cFmt.setBackground(SHARED.theme.colMark) + cFmt.setBackground(self._theme.highlight) elif fmt == self.FMT_M_E: cFmt.setBackground(QtTransparent) elif fmt == self.FMT_SUP_B: @@ -297,7 +322,7 @@ class ToQTextDocument(Tokenizer): elif fmt == self.FMT_SUB_E: cFmt.setVerticalAlignment(QtVAlignNormal) elif fmt == self.FMT_FNOTE: - xFmt = QTextCharFormat(self._codeChar) + xFmt = QTextCharFormat(self._cCode) xFmt.setVerticalAlignment(QtVAlignSuper) if data in self._footnotes: index = len(self._usedNotes) + 1 @@ -322,37 +347,36 @@ class ToQTextDocument(Tokenizer): valid, bits, _ = self._project.index.scanThis("@"+text) if valid and bits: key = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: " - cursor.insertText(key, self._keyChar) + cursor.insertText(key, self._cKeyword) if (num := len(bits)) > 1: if bits[0] == nwKeyWords.TAG_KEY: one, two = self._project.index.parseValue(bits[1]) - cursor.insertText(one, self._tagChar) + cursor.insertText(one, self._cTag) if two: - cursor.insertText(" | ", self._defaultChar) - cursor.insertText(two, self._optChar) + cursor.insertText(" | ", self._cText) + cursor.insertText(two, self._cOptional) else: for n, bit in enumerate(bits[1:], 2): - cFmt = QTextCharFormat(self._tagChar) + cFmt = QTextCharFormat(self._cTag) cFmt.setFontUnderline(True) cFmt.setAnchor(True) cFmt.setAnchorHref(f"#{bits[0][1:]}={bit}") cursor.insertText(bit, cFmt) if n < num: - cursor.insertText(", ", self._defaultChar) + cursor.insertText(", ", self._cText) return - def _genHeadStyle(self, level: int, nHead: int, rFmt: QTextBlockFormat) -> T_TextStyle: + def _genHeadStyle(self, hType: int, nHead: int, rFmt: QTextBlockFormat) -> T_TextStyle: """Generate a heading style set.""" - mTop, mBottom = self._mHead.get(level, (0.0, 0.0)) + mTop, mBottom = self._mHead.get(hType, (0.0, 0.0)) bFmt = QTextBlockFormat(rFmt) bFmt.setTopMargin(mTop) bFmt.setBottomMargin(mBottom) - cFmt = QTextCharFormat(self._defaultChar) - cFmt.setForeground(SHARED.theme.colHead) - cFmt.setFontWeight(QFont.Weight.Bold) - cFmt.setFontPointSize(self._sHead.get(level, 1.0)) + cFmt = QTextCharFormat(self._cText if hType == self.T_TITLE else self._cHead) + cFmt.setFontWeight(self._bold) + cFmt.setFontPointSize(self._sHead.get(hType, 1.0)) if nHead >= 0: cFmt.setAnchorNames([f"{self._handle}:T{nHead:04d}"]) cFmt.setAnchor(True) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 6c3569df..4eda952c 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -40,7 +40,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.common import cssCol from novelwriter.constants import nwHeaders, nwUnicode -from novelwriter.core.toqdoc import ToQTextDocument +from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument from novelwriter.enum import nwDocAction, nwDocMode, nwItemType from novelwriter.error import logException from novelwriter.extensions.eventfilters import WheelEventFilter @@ -69,6 +69,7 @@ class GuiDocViewer(QTextBrowser): # Internal Variables self._docHandle = None + self._docTheme = TextDocumentTheme() # Settings self.setMinimumWidth(CONFIG.pxInt(300)) @@ -152,6 +153,17 @@ class GuiDocViewer(QTextBrowser): docPalette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.viewport().setPalette(docPalette) + self._docTheme.text = SHARED.theme.colText + self._docTheme.highlight = SHARED.theme.colMark + self._docTheme.head = SHARED.theme.colHead + self._docTheme.comment = SHARED.theme.colHidden + self._docTheme.note = SHARED.theme.colNote + self._docTheme.code = SHARED.theme.colCode + self._docTheme.modifier = SHARED.theme.colMod + self._docTheme.keyword = SHARED.theme.colKey + self._docTheme.tag = SHARED.theme.colTag + self._docTheme.optional = SHARED.theme.colOpt + self.docHeader.matchColours() self.docFooter.matchColours() @@ -203,11 +215,10 @@ class GuiDocViewer(QTextBrowser): sPos = self.verticalScrollBar().value() qDoc = ToQTextDocument(SHARED.project) - qDoc.initDocument(CONFIG.textFont) + qDoc.initDocument(CONFIG.textFont, self._docTheme) qDoc.setKeywords(True) qDoc.setComments(CONFIG.viewComments) qDoc.setSynopsis(CONFIG.viewSynopsis) - qDoc.setLinkHeadings(True) # Be extra careful here to prevent crashes when first opening a # project as a crash here leaves no way of recovering. diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 7e27b867..84c043dc 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -31,7 +31,7 @@ from time import time from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QTimer, QUrl, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QCloseEvent, QColor, QCursor, QFont, QPalette, QResizeEvent +from PyQt5.QtGui import QCloseEvent, QColor, QCursor, QFont, QPalette, QResizeEvent, QTextDocument from PyQt5.QtPrintSupport import QPrinter, QPrintPreviewDialog from PyQt5.QtWidgets import ( QAbstractItemView, QApplication, QFormLayout, QGridLayout, QHBoxLayout, @@ -44,8 +44,8 @@ from novelwriter import CONFIG, SHARED from novelwriter.common import checkInt, fuzzyTime from novelwriter.core.buildsettings import BuildCollection, BuildSettings from novelwriter.core.docbuild import NWBuildDocument -from novelwriter.core.tohtml import ToHtml from novelwriter.core.tokenizer import HeadingFormatter +from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument from novelwriter.error import logException from novelwriter.extensions.circularprogress import NProgressCircle from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton, NToolDialog @@ -250,19 +250,19 @@ class GuiManuscript(NToolDialog): if selected in self._buildMap: self.buildList.setCurrentItem(self._buildMap[selected]) - logger.debug("Loading build cache") - cache = CONFIG.dataPath("cache") / f"build_{SHARED.project.data.uuid}.json" - if cache.is_file(): - try: - with open(cache, mode="r", encoding="utf-8") as fObj: - data = json.load(fObj) - build = self._builds.getBuild(data.get("uuid", "")) - if isinstance(build, BuildSettings): - self._updatePreview(data, build) - except Exception: - logger.error("Failed to load build cache") - logException() - return + # logger.debug("Loading build cache") + # cache = CONFIG.dataPath("cache") / f"build_{SHARED.project.data.uuid}.json" + # if cache.is_file(): + # try: + # with open(cache, mode="r", encoding="utf-8") as fObj: + # data = json.load(fObj) + # build = self._builds.getBuild(data.get("uuid", "")) + # if isinstance(build, BuildSettings): + # self._updatePreview(data, build) + # except Exception: + # logger.error("Failed to load build cache") + # logException() + # return return @@ -345,29 +345,38 @@ class GuiManuscript(NToolDialog): docBuild.setPreviewMode(True) docBuild.queueAll() + theme = TextDocumentTheme() + theme.text = QColor(0, 0, 0) + theme.highlight = QColor(255, 255, 166) + theme.head = QColor(66, 113, 174) + theme.comment = QColor(100, 100, 100) + theme.note = QColor(129, 55, 9) + theme.code = QColor(66, 113, 174) + theme.modifier = QColor(129, 55, 9) + theme.keyword = QColor(245, 135, 31) + theme.tag = QColor(66, 113, 174) + theme.optional = QColor(66, 113, 174) + self.docPreview.beginNewBuild(len(docBuild)) - for step, _ in docBuild.iterBuildHTML(None): + for step, _ in docBuild.iterBuildPreview(theme): self.docPreview.buildStep(step + 1) QApplication.processEvents() buildObj = docBuild.lastBuild - assert isinstance(buildObj, ToHtml) - result = { + assert isinstance(buildObj, ToQTextDocument) + data = { "uuid": build.buildID, "time": int(time()), "stats": buildObj.textStats, "outline": buildObj.textOutline, - "styles": buildObj.getStyleSheet(), - "html": buildObj.fullHTML, } - - self._updatePreview(result, build) + self._updatePreview(data, build, buildObj.document) logger.debug("Saving build cache") cache = CONFIG.dataPath("cache") / f"build_{SHARED.project.data.uuid}.json" try: with open(cache, mode="w+", encoding="utf-8") as outFile: - outFile.write(json.dumps(result, indent=2)) + outFile.write(json.dumps(data, indent=2)) except Exception: logger.error("Failed to save build cache") logException() @@ -400,17 +409,14 @@ class GuiManuscript(NToolDialog): # Internal Functions ## - def _updatePreview(self, data: dict, build: BuildSettings) -> None: + def _updatePreview(self, data: dict, build: BuildSettings, document: QTextDocument) -> None: """Update the preview widget and set relevant values.""" - textFont = QFont() - textFont.fromString(build.getStr("format.textFont")) - - self.docPreview.setContent(data) + font = QFont() + font.fromString(build.getStr("format.textFont")) + self.docPreview.setTextFont(font) + self.docPreview.setContent(data, document) self.docPreview.setBuildName(build.name) - self.docPreview.setTextFont(textFont) - self.docPreview.setJustify( - build.getBool("format.justifyText") - ) + self.docPreview.setJustify(build.getBool("format.justifyText")) self.docStats.updateStats(data.get("stats", {})) self.buildOutline.updateOutline(data.get("outline", {})) return @@ -848,30 +854,22 @@ class _PreviewWidget(QTextBrowser): QApplication.processEvents() return - def setContent(self, data: dict) -> None: + def setContent(self, data: dict, doc: QTextDocument) -> None: """Set the content of the preview widget.""" QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) self.buildProgress.setCentreText(self.tr("Processing ...")) QApplication.processEvents() - styles = "\n".join(data.get("styles", [])) - self.document().setDefaultStyleSheet(styles) - - html = "".join(data.get("html", [])) - html = html.replace("\t", "!!tab!!") - self.setHtml(html) - QApplication.processEvents() - while self.find("!!tab!!"): - cursor = self.textCursor() - cursor.insertText("\t") + doc.setDocumentMargin(CONFIG.getTextMargin()) + self.setDocument(doc) self._docTime = checkInt(data.get("time"), 0) self._updateBuildAge() # Since we change the content while it may still be rendering, we mark # the document as dirty again to make sure it's re-rendered properly. - self.document().markContentsDirty(0, self.document().characterCount()) + # self.document().markContentsDirty(0, self.document().characterCount()) self.buildProgress.setCentreText(self.tr("Done")) QApplication.restoreOverrideCursor() diff --git a/novelwriter/types.py b/novelwriter/types.py index c45d79e0..8cdfb1db 100644 --- a/novelwriter/types.py +++ b/novelwriter/types.py @@ -56,6 +56,7 @@ QtPageBreakAfter = QTextFormat.PageBreakFlag.PageBreak_AlwaysAfter # Qt Painter Types QtTransparent = QColor(0, 0, 0, 0) +QtBlack = QColor(0, 0, 0) QtNoBrush = Qt.BrushStyle.NoBrush QtNoPen = Qt.PenStyle.NoPen QtRoundCap = Qt.PenCapStyle.RoundCap