""" novelWriter – GUI Syntax Highlighter ==================================== File History: Created: 2019-04-06 [0.0.1] 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 import logging from time import time from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtGui import ( QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat, QTextDocument ) from novelwriter import CONFIG, SHARED from novelwriter.common import checkInt from novelwriter.constants import nwRegEx, nwUnicode logger = logging.getLogger(__name__) SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—]+\b") SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) class GuiDocHighlighter(QSyntaxHighlighter): BLOCK_NONE = 0 BLOCK_TEXT = 1 BLOCK_META = 2 BLOCK_TITLE = 4 def __init__(self, document: QTextDocument) -> None: super().__init__(document) logger.debug("Create: GuiDocHighlighter") self._tItem = None self._tHandle = None self._spellCheck = False self._hRules: list[tuple[str, dict]] = [] self._hStyles: dict[str, QTextCharFormat] = {} self._colHead = QColor(0, 0, 0) self._colHeadH = QColor(0, 0, 0) self._colEmph = QColor(0, 0, 0) self._colDialN = QColor(0, 0, 0) self._colDialD = QColor(0, 0, 0) self._colDialS = QColor(0, 0, 0) self._colHidden = QColor(0, 0, 0) self._colKey = QColor(0, 0, 0) self._colVal = QColor(0, 0, 0) self._colSpell = QColor(0, 0, 0) self._colError = QColor(0, 0, 0) self._colRepTag = QColor(0, 0, 0) self._colMod = QColor(0, 0, 0) self._colBreak = QColor(0, 0, 0) self.initHighlighter() logger.debug("Ready: GuiDocHighlighter") return def initHighlighter(self) -> None: """Initialise the syntax highlighter, setting all the colour rules and building the RegExes. """ logger.debug("Setting up highlighting rules") self._colHead = QColor(*SHARED.theme.colHead) self._colHeadH = QColor(*SHARED.theme.colHeadH) self._colDialN = QColor(*SHARED.theme.colDialN) self._colDialD = QColor(*SHARED.theme.colDialD) self._colDialS = QColor(*SHARED.theme.colDialS) self._colHidden = QColor(*SHARED.theme.colHidden) self._colKey = QColor(*SHARED.theme.colKey) self._colVal = QColor(*SHARED.theme.colVal) self._colSpell = QColor(*SHARED.theme.colSpell) self._colError = QColor(*SHARED.theme.colError) self._colRepTag = QColor(*SHARED.theme.colRepTag) self._colMod = QColor(*SHARED.theme.colMod) self._colBreak = QColor(*SHARED.theme.colEmph) self._colBreak.setAlpha(64) self._colEmph = None if CONFIG.highlightEmph: self._colEmph = QColor(*SHARED.theme.colEmph) self._hStyles = { "header1": self._makeFormat(self._colHead, "bold", 1.8), "header2": self._makeFormat(self._colHead, "bold", 1.6), "header3": self._makeFormat(self._colHead, "bold", 1.4), "header4": self._makeFormat(self._colHead, "bold", 1.2), "header1h": self._makeFormat(self._colHeadH, "bold", 1.8), "header2h": self._makeFormat(self._colHeadH, "bold", 1.6), "header3h": self._makeFormat(self._colHeadH, "bold", 1.4), "header4h": self._makeFormat(self._colHeadH, "bold", 1.2), "bold": self._makeFormat(self._colEmph, "bold"), "italic": self._makeFormat(self._colEmph, "italic"), "strike": self._makeFormat(self._colHidden, "strike"), "mspaces": self._makeFormat(self._colError, "errline"), "nobreak": self._makeFormat(self._colBreak, "background"), "dialogue1": self._makeFormat(self._colDialN), "dialogue2": self._makeFormat(self._colDialD), "dialogue3": self._makeFormat(self._colDialS), "replace": self._makeFormat(self._colRepTag), "hidden": self._makeFormat(self._colHidden), "keyword": self._makeFormat(self._colKey), "modifier": self._makeFormat(self._colMod), "value": self._makeFormat(self._colVal, "underline"), "codevalue": self._makeFormat(self._colVal), "codeinval": self._makeFormat(None, "errline"), } self._hRules = [] # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: self._hRules.append(( r"[ ]{2,}|[ ]*$", { 0: self._hStyles["mspaces"], } )) # Non-Breaking Spaces self._hRules.append(( f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", { 0: self._hStyles["nobreak"], } )) # Quoted Strings if CONFIG.highlightQuotes: fmtDblO = CONFIG.fmtDQuoteOpen fmtDblC = CONFIG.fmtDQuoteClose fmtSngO = CONFIG.fmtSQuoteOpen fmtSngC = CONFIG.fmtSQuoteClose # Straight Quotes if not (fmtDblO == fmtDblC == "\""): self._hRules.append(( "(\\B\")(.*?)(\"\\B)", { 0: self._hStyles["dialogue1"], } )) # Double Quotes dblEnd = "|$" if CONFIG.allowOpenDQuote else "" self._hRules.append(( f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", { 0: self._hStyles["dialogue2"], } )) # Single Quotes sngEnd = "|$" if CONFIG.allowOpenSQuote else "" self._hRules.append(( f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", { 0: self._hStyles["dialogue3"], } )) # Markdown Syntax self._hRules.append(( nwRegEx.FMT_EI, { 1: self._hStyles["hidden"], 2: self._hStyles["italic"], 3: self._hStyles["hidden"], } )) self._hRules.append(( nwRegEx.FMT_EB, { 1: self._hStyles["hidden"], 2: self._hStyles["bold"], 3: self._hStyles["hidden"], } )) self._hRules.append(( nwRegEx.FMT_ST, { 1: self._hStyles["hidden"], 2: self._hStyles["strike"], 3: self._hStyles["hidden"], } )) # Alignment Tags self._hRules.append(( r"(^>{1,2}|<{1,2}$)", { 1: self._hStyles["hidden"], } )) # Auto-Replace Tags self._hRules.append(( r"<(\S+?)>", { 0: self._hStyles["replace"], } )) # Build a QRegExp for each highlight pattern self.rxRules = [] for regEx, regRules in self._hRules: hReg = QRegularExpression(regEx) hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) self.rxRules.append((hReg, regRules)) return ## # Setters ## def setSpellCheck(self, state: bool) -> None: """Enable/disable the real time spell checker.""" self._spellCheck = state return def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle self._tItem = SHARED.project.tree[tHandle] logger.debug( "Syntax highlighter %s for item '%s'", "enabled" if self._tItem else "disabled", tHandle ) return ## # Methods ## def rehighlightByType(self, cType: int) -> None: """Loop through all blocks and re-highlight those of a given content type. """ qDoc = self.document() nBlocks = qDoc.blockCount() tStart = time() for i in range(nBlocks): theBlock = qDoc.findBlockByNumber(i) if theBlock.userState() & cType > 0: self.rehighlightBlock(theBlock) logger.debug("Document highlighted in %.3f ms" % (1000*(time() - tStart))) return ## # Highlight Block ## def highlightBlock(self, text: str) -> None: """Highlight a single block. Prefer to check first character for all formats that are defined by their initial characters. This is significantly faster than running the regex checks used for text paragraphs. """ self.setCurrentBlockState(self.BLOCK_NONE) if self._tHandle is None or not text: return if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(self.BLOCK_META) if self._tItem: pIndex = SHARED.project.index isValid, theBits, thePos = pIndex.scanThis(text) isGood = pIndex.checkThese(theBits, self._tItem) if isValid: for n, theBit in enumerate(theBits): xPos = thePos[n] xLen = len(theBit) if isGood[n]: if n == 0: self.setFormat(xPos, xLen, self._hStyles["keyword"]) else: self.setFormat(xPos, xLen, self._hStyles["value"]) else: kwFmt = self.format(xPos) kwFmt.setUnderlineColor(self._colError) kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) self.setFormat(xPos, xLen, kwFmt) # We never want to run the spell checker on keyword/values, # so we force a return here return elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")): self.setCurrentBlockState(self.BLOCK_TITLE) if text.startswith("# "): # Header 1 self.setFormat(0, 1, self._hStyles["header1h"]) self.setFormat(1, len(text), self._hStyles["header1"]) elif text.startswith("## "): # Header 2 self.setFormat(0, 2, self._hStyles["header2h"]) self.setFormat(2, len(text), self._hStyles["header2"]) elif text.startswith("### "): # Header 3 self.setFormat(0, 3, self._hStyles["header3h"]) self.setFormat(3, len(text), self._hStyles["header3"]) elif text.startswith("#### "): # Header 4 self.setFormat(0, 4, self._hStyles["header4h"]) self.setFormat(4, len(text), self._hStyles["header4"]) elif text.startswith("#! "): # Title self.setFormat(0, 2, self._hStyles["header1h"]) self.setFormat(2, len(text), self._hStyles["header1"]) elif text.startswith("##! "): # Unnumbered self.setFormat(0, 3, self._hStyles["header2h"]) self.setFormat(3, len(text), self._hStyles["header2"]) elif text.startswith("%"): # Comments self.setCurrentBlockState(self.BLOCK_TEXT) toCheck = text[1:].lstrip() synTag = toCheck[:9].lower() tLen = len(text) cLen = len(toCheck) cOff = tLen - cLen if synTag == "synopsis:": self.setFormat(0, cOff+9, self._hStyles["modifier"]) self.setFormat(cOff+9, tLen, self._hStyles["hidden"]) else: self.setFormat(0, tLen, self._hStyles["hidden"]) else: # Text Paragraph if text.startswith("["): # Special Command sText = text.rstrip() if sText in ("[NEWPAGE]", "[NEW PAGE]", "[VSPACE]"): self.setFormat(0, len(text), self._hStyles["keyword"]) return elif sText.startswith("[VSPACE:") and sText.endswith("]"): tLen = len(sText) tVal = checkInt(sText[8:-1], 0) cVal = "codevalue" if tVal > 0 else "codeinval" self.setFormat(0, 8, self._hStyles["keyword"]) self.setFormat(8, tLen-9, self._hStyles[cVal]) self.setFormat(tLen-1, tLen, self._hStyles["keyword"]) return # Regular Text self.setCurrentBlockState(self.BLOCK_TEXT) for rX, xFmt in self.rxRules: rxItt = rX.globalMatch(text, 0) while rxItt.hasNext(): rxMatch = rxItt.next() for xM in xFmt: xPos = rxMatch.capturedStart(xM) xLen = rxMatch.capturedLength(xM) for x in range(xPos, xPos+xLen): spFmt = self.format(x) if spFmt != self._hStyles["hidden"]: spFmt.merge(xFmt[xM]) self.setFormat(x, 1, spFmt) data = self.currentBlockUserData() if not isinstance(data, TextBlockData): data = TextBlockData() self.setCurrentBlockUserData(data) if self._spellCheck: for xPos, xLen in data.spellCheck(text): for x in range(xPos, xPos+xLen): spFmt = self.format(x) spFmt.setUnderlineColor(self._colSpell) spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) self.setFormat(x, 1, spFmt) return ## # Internal Functions ## def _makeFormat(self, color: QColor | None = None, style: str | None = None, size: float | None = None) -> QTextCharFormat: """Generate a valid character format to be applied to the text that is to be highlighted. """ charFormat = QTextCharFormat() if color is not None: charFormat.setForeground(color) if style is not None: styles = style.split(",") if "bold" in styles: charFormat.setFontWeight(QFont.Bold) if "italic" in styles: charFormat.setFontItalic(True) if "strike" in styles: charFormat.setFontStrikeOut(True) if "errline" in styles: charFormat.setUnderlineColor(self._colError) charFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) if "underline" in styles: charFormat.setFontUnderline(True) if "background" in styles and color is not None: charFormat.setBackground(QBrush(color, Qt.SolidPattern)) if size is not None: charFormat.setFontPointSize(int(round(size*CONFIG.textSize))) return charFormat # END Class GuiDocHighlighter class TextBlockData(QTextBlockUserData): __slots__ = ("_spellErrors") def __init__(self) -> None: super().__init__() self._spellErrors: list[tuple[int, int]] = [] return @property def spellErrors(self) -> list[tuple[int, int]]: """Return spell error data from last check.""" return self._spellErrors def spellCheck(self, text: str) -> list[tuple[int, int]]: """Run the spell checker and cache the result, and return the list of spell check errors. """ self._spellErrors = [] rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0) while rxSpell.hasNext(): rxMatch = rxSpell.next() if not SHARED.spelling.checkWord(rxMatch.captured(0)): if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper(): self._spellErrors.append((rxMatch.capturedStart(0), rxMatch.capturedLength(0))) return self._spellErrors # END Class TextBlockData