From 1203dd639b73a11ef15ac03f66e1a486de0f8599 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 May 2024 18:12:08 +0200 Subject: [PATCH] Add a QTextDocument generator class --- novelwriter/core/tokenizer.py | 1 + novelwriter/core/toqdocument.py | 287 ++++++++++++++++++++++++++++++++ novelwriter/gui/docviewer.py | 31 ++-- 3 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 novelwriter/core/toqdocument.py diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 15cd8046..f0c586f0 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -152,6 +152,7 @@ class Tokenizer(ABC): self._doComments = False # Also process comments self._doKeywords = False # Also process keywords like tags and references self._skipKeywords = set() # Keywords to ignore + self._keepBreaks = True # Keep line breaks in paragraphs # Margins self._marginTitle = (1.000, 0.500) diff --git a/novelwriter/core/toqdocument.py b/novelwriter/core/toqdocument.py new file mode 100644 index 00000000..08fcc179 --- /dev/null +++ b/novelwriter/core/toqdocument.py @@ -0,0 +1,287 @@ +""" +novelWriter – Markdown Text Converter +===================================== + +File History: +Created: 2021-02-06 [1.2b1] ToMarkdown + +This file is a part of novelWriter +Copyright 2018–2024, 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 + +import logging + +from PyQt5.QtGui import ( + QFont, QFontMetrics, QTextBlockFormat, QTextCharFormat, QTextCursor, + QTextDocument +) + +from novelwriter import SHARED +from novelwriter.constants import nwHeaders +from novelwriter.core.project import NWProject +from novelwriter.core.tokenizer import T_Formats, Tokenizer +from novelwriter.types import ( + QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight, QtPageBreakAfter, + QtPageBreakBefore +) + +logger = logging.getLogger(__name__) + +T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat] + + +class ToQTextDocument(Tokenizer): + """Core: QTextDocument Writer + + Extend the Tokenizer class to generate a QTextDocument output. This + is intended for usage in the document viewer and build tool preview. + """ + + def __init__(self, project: NWProject) -> None: + super().__init__(project) + self._document = QTextDocument() + self._document.setUndoRedoEnabled(False) + + self._styles: dict[int, T_TextStyle] = {} + self._init = False + + return + + def initDocument(self, font: QFont | None = None) -> None: + """Initialise all computed values of the document.""" + if not font: + font = QFont() + font.setFamily(self._textFont) + font.setPointSize(self._textSize) + + self._document.setUndoRedoEnabled(False) + self._document.blockSignals(True) + self._document.clear() + self._document.setDefaultFont(font) + + qMetric = QFontMetrics(font) + mScale = qMetric.height() + fPt = font.pointSizeF() + + self._mHead = { + self.T_TITLE: (mScale * self._marginTitle[0], mScale * self._marginTitle[1]), + self.T_HEAD1: (mScale * self._marginHead1[0], mScale * self._marginHead1[1]), + self.T_HEAD2: (mScale * self._marginHead2[0], mScale * self._marginHead2[1]), + self.T_HEAD3: (mScale * self._marginHead3[0], mScale * self._marginHead3[1]), + self.T_HEAD4: (mScale * self._marginHead4[0], mScale * self._marginHead4[1]), + } + + self._sHead = { + self.T_TITLE: nwHeaders.H_SIZES.get(1, 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, + self.T_HEAD4: nwHeaders.H_SIZES.get(4, 1.0) * fPt, + } + + self._mText = (mScale * self._marginText[0], mScale * self._marginText[1]) + self._mMeta = (mScale * self._marginMeta[0], mScale * self._marginMeta[1]) + + self._defaultChar = QTextCharFormat() + self._defaultChar.setForeground(SHARED.theme.colText) + + self._defaultBlock = QTextBlockFormat() + self._defaultBlock.setTopMargin(self._mText[0]) + self._defaultBlock.setBottomMargin(self._mText[1]) + + self._init = True + + return + + ## + # Properties + ## + + @property + def document(self) -> QTextDocument: + """Return the document.""" + return self._document + + ## + # Class Methods + ## + + def clearDocument(self) -> None: + """Clear the document so the class can be reused.""" + self._document.clear() + return + + def doConvert(self) -> None: + """Write text tokens into the document.""" + if not self._init: + return + + self._document.blockSignals(True) + cursor = QTextCursor(self._document) + + def newBlock(bFmt: QTextBlockFormat) -> None: + if cursor.position() > 0: + cursor.insertBlock(bFmt) + else: + cursor.setBlockFormat(bFmt) + + pText = [] + pFmt = None + lineSep = "\n" if self._keepBreaks else " " + + for tType, _, tText, tFormat, tStyle in self._tokens: + + # Styles + bFmt = QTextBlockFormat(self._defaultBlock) + if tStyle is not None: + if tStyle & self.A_LEFT: + bFmt.setAlignment(QtAlignLeft) + elif tStyle & self.A_RIGHT: + bFmt.setAlignment(QtAlignRight) + elif tStyle & self.A_CENTRE: + bFmt.setAlignment(QtAlignCenter) + elif tStyle & self.A_JUSTIFY: + bFmt.setAlignment(QtAlignJustify) + + if tStyle & self.A_PBB: + bFmt.setPageBreakPolicy(QtPageBreakBefore) + if tStyle & self.A_PBA: + bFmt.setPageBreakPolicy(QtPageBreakAfter) + + if tStyle & self.A_Z_BTMMRG: + bFmt.setBottomMargin(0.0) + if tStyle & self.A_Z_TOPMRG: + bFmt.setTopMargin(0.0) + + if tStyle & self.A_IND_L: + bFmt.setLeftMargin(self._blockIndent) + if tStyle & self.A_IND_R: + bFmt.setRightMargin(self._blockIndent) + + if tType == self.T_EMPTY: + if pText and pFmt is not None: + tTemp = (lineSep.join(pText)).rstrip(" ") + newBlock(pFmt) + cursor.insertText(tTemp, self._defaultChar) + pText = [] + pFmt = None + + elif tType in self.L_HEADINGS: + bFmt, cFmt = self._genHeadStyle(tType, bFmt) + newBlock(bFmt) + cursor.insertText(tText, cFmt) + + elif tType == self.T_SEP: + newBlock(bFmt) + cursor.insertText(tTemp, self._defaultChar) + + elif tType == self.T_SKIP: + newBlock(bFmt) + + elif tType == self.T_TEXT: + if pFmt is None: + pFmt = bFmt + pText.append(self._formatText(tText, tFormat).rstrip()) + + elif tType == self.T_SYNOPSIS and self._doSynopsis: + pass + + elif tType == self.T_SHORT and self._doSynopsis: + pass + + elif tType == self.T_COMMENT and self._doComments: + pass + + elif tType == self.T_KEYWORD and self._doKeywords: + pass + + self._document.blockSignals(False) + + return + + def appendFootnotes(self) -> None: + """Append the footnotes in the buffer.""" + # if self._usedNotes: + # tags = STD_MD if self._genMode == self.M_STD else EXT_MD + # footnotes = self._localLookup("Footnotes") + + # lines = [] + # lines.append(f"### {footnotes}\n\n") + # for key, index in self._usedNotes.items(): + # if content := self._footnotes.get(key): + # marker = f"{index}. " + # text = self._formatText(*content, tags) + # lines.append(f"{marker}{text}\n") + # lines.append("\n") + + # result = "".join(lines) + # self._result += result + # self._fullMD.append(result) + + return + + ## + # Internal Functions + ## + + def _formatText(self, text: str, tFmt: T_Formats) -> str: + """Apply formatting tags to text.""" + temp = text + # for pos, fmt, data in reversed(tFmt): + # md = "" + # if fmt == self.FMT_FNOTE: + # if data in self._footnotes: + # index = len(self._usedNotes) + 1 + # self._usedNotes[data] = index + # md = f"[{index}]" + # else: + # md = "[ERR]" + # else: + # md = tags.get(fmt, "") + # temp = f"{temp[:pos]}{md}{temp[pos:]}" + return temp + + def _formatKeywords(self, text: str, style: int) -> str: + """Apply Markdown formatting to keywords.""" + # valid, bits, _ = self._project.index.scanThis("@"+text) + # if not valid or not bits: + # return "" + + result = "" + # if bits[0] in nwLabels.KEY_NAME: + # result += f"**{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:** " + # if len(bits) > 1: + # result += ", ".join(bits[1:]) + + # result += " \n" if style & self.A_Z_BTMMRG else "\n\n" + + return result + + def _genHeadStyle(self, level: int, rFmt: QTextBlockFormat) -> T_TextStyle: + """Generate a heading style set.""" + mTop, mBottom = self._mHead.get(level, (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)) + + return bFmt, cFmt diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 917a4f7f..082233bb 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.tohtml import ToHtml +from novelwriter.core.toqdocument import ToQTextDocument from novelwriter.enum import nwDocAction, nwDocMode, nwItemType from novelwriter.error import logException from novelwriter.extensions.eventfilters import WheelEventFilter @@ -202,22 +202,22 @@ class GuiDocViewer(QTextBrowser): QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) sPos = self.verticalScrollBar().value() - aDoc = ToHtml(SHARED.project) - aDoc.setPreview(True) - aDoc.setKeywords(True) - aDoc.setComments(CONFIG.viewComments) - aDoc.setSynopsis(CONFIG.viewSynopsis) - aDoc.setLinkHeadings(True) + qDoc = ToQTextDocument(SHARED.project) + qDoc.initDocument(CONFIG.textFont) + 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. # See issue #298 try: - aDoc.setText(tHandle) - aDoc.doPreProcessing() - aDoc.tokenizeText() - aDoc.doConvert() - aDoc.appendFootnotes() + qDoc.setText(tHandle) + qDoc.doPreProcessing() + qDoc.tokenizeText() + qDoc.doConvert() + qDoc.appendFootnotes() except Exception: logger.error("Failed to generate preview for document with handle '%s'", tHandle) logException() @@ -235,9 +235,10 @@ class GuiDocViewer(QTextBrowser): self.setDocumentTitle(tHandle) # Replace tabs before setting the HTML, and then put them back in - self.setHtml(aDoc.result.replace("\t", "!!tab!!")) - while self.find("!!tab!!"): - self.textCursor().insertText("\t") + # self.setHtml(qDoc.result.replace("\t", "!!tab!!")) + # while self.find("!!tab!!"): + # self.textCursor().insertText("\t") + self.setDocument(qDoc.document) if self._docHandle == tHandle: # This is a refresh, so we set the scrollbar back to where it was