""" novelWriter – GUI Syntax Highlighter ==================================== File History: Created: 2019-04-06 [0.0.1] GuiDocHighlighter Created: 2023-09-10 [2.2b1] TextBlockData This file is a part of novelWriter Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors 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 . """ # noqa from __future__ import annotations import logging import re from time import time from PyQt6.QtCore import Qt from PyQt6.QtGui import ( QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat, QTextDocument ) from novelwriter import CONFIG, SHARED from novelwriter.common import checkInt, utf16CharMap from novelwriter.constants import nwStyles, nwUnicode from novelwriter.enum import nwComment from novelwriter.text.comments import processComment from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser from novelwriter.types import QtTextUserProperty logger = logging.getLogger(__name__) RX_URL = REGEX_PATTERNS.url RX_WORDS = REGEX_PATTERNS.wordSplit RX_FMT_SC = REGEX_PATTERNS.shortcodePlain RX_FMT_SV = REGEX_PATTERNS.shortcodeValue BLOCK_NONE = 0 BLOCK_TEXT = 1 BLOCK_META = 2 BLOCK_TITLE = 4 class GuiDocHighlighter(QSyntaxHighlighter): """GUI: Editor Syntax Highlighter.""" __slots__ = ( "_cmnRules", "_dialogParser", "_hStyles", "_isInactive", "_isNovel", "_minRules", "_spellCheck", "_spellErr", "_tHandle", "_txtRules", ) def __init__(self, document: QTextDocument) -> None: super().__init__(document) logger.debug("Create: GuiDocHighlighter") self._tHandle = None self._isNovel = False self._isInactive = False self._spellCheck = False self._spellErr = QTextCharFormat() self._hStyles: dict[str, QTextCharFormat] = {} self._minRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = [] self._txtRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = [] self._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = [] self._dialogParser = DialogParser() self.initHighlighter() logger.debug("Ready: GuiDocHighlighter") def initHighlighter(self) -> None: """Initialise the syntax highlighter, setting all the colour rules and building the RegExes. """ logger.debug("Setting up highlighting rules") syntax = SHARED.theme.syntaxTheme colEmph = syntax.emph if CONFIG.highlightEmph else None # Create Character Formats self._addCharFormat("text", syntax.text) self._addCharFormat("header1", syntax.head, "b", nwStyles.H_SIZES[1]) self._addCharFormat("header2", syntax.head, "b", nwStyles.H_SIZES[2]) self._addCharFormat("header3", syntax.head, "b", nwStyles.H_SIZES[3]) self._addCharFormat("header4", syntax.head, "b", nwStyles.H_SIZES[4]) self._addCharFormat("head1h", syntax.headH, "b", nwStyles.H_SIZES[1]) self._addCharFormat("head2h", syntax.headH, "b", nwStyles.H_SIZES[2]) self._addCharFormat("head3h", syntax.headH, "b", nwStyles.H_SIZES[3]) self._addCharFormat("head4h", syntax.headH, "b", nwStyles.H_SIZES[4]) self._addCharFormat("bold", colEmph, "b") self._addCharFormat("italic", colEmph, "i") self._addCharFormat("strike", syntax.hidden, "s") self._addCharFormat("mark", syntax.mark, "bg") self._addCharFormat("mspaces", syntax.error, "err") self._addCharFormat("nobreak", syntax.space, "bg") self._addCharFormat("altdialog", syntax.dialA) self._addCharFormat("dialog", syntax.dialN) self._addCharFormat("replace", syntax.repTag) self._addCharFormat("hidden", syntax.hidden) self._addCharFormat("markup", syntax.hidden) self._addCharFormat("link", syntax.link, "u") self._addCharFormat("note", syntax.note) self._addCharFormat("code", syntax.code) self._addCharFormat("keyword", syntax.key) self._addCharFormat("tag", syntax.tag, "u") self._addCharFormat("modifier", syntax.mod) self._addCharFormat("value", syntax.val) self._addCharFormat("optional", syntax.opt) self._addCharFormat("invalid", None, "err") # Cache Spell Error Format self._spellErr = QTextCharFormat() self._spellErr.setUnderlineColor(syntax.spell) self._spellErr.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline) self._txtRules.clear() self._cmnRules.clear() self._dialogParser.initParser() # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: rxRule = re.compile(r"\s{2,}") hlRule = { 0: self._hStyles["mspaces"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Non-Breaking Spaces rxRule = re.compile(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+") hlRule = { 0: self._hStyles["nobreak"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Alt Dialogue if rxRule := REGEX_PATTERNS.altDialogStyle: hlRule = { 0: self._hStyles["altdialog"], } self._txtRules.append((rxRule, hlRule)) # Markdown Italic rxRule = REGEX_PATTERNS.markdownItalic hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["italic"], 3: self._hStyles["markup"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Markdown Bold rxRule = REGEX_PATTERNS.markdownBold hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["bold"], 3: self._hStyles["markup"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Markdown Strikethrough rxRule = REGEX_PATTERNS.markdownStrike hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["strike"], 3: self._hStyles["markup"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Markdown Highlight rxRule = REGEX_PATTERNS.markdownMark hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["mark"], 3: self._hStyles["markup"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Shortcodes rxRule = REGEX_PATTERNS.shortcodePlain hlRule = { 1: self._hStyles["code"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Shortcodes w/Value rxRule = REGEX_PATTERNS.shortcodeValue hlRule = { 1: self._hStyles["code"], 2: self._hStyles["value"], 3: self._hStyles["code"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # URLs rxRule = REGEX_PATTERNS.url hlRule = { 0: self._hStyles["link"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Alignment Tags rxRule = re.compile(r"(^>{1,2}|<{1,2}$)") hlRule = { 1: self._hStyles["markup"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) # Auto-Replace Tags rxRule = re.compile(r"<(\S+?)>") hlRule = { 0: self._hStyles["replace"], } self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) ## # Setters ## def setSpellCheck(self, state: bool) -> None: """Enable/disable the real time spell checker.""" self._spellCheck = state def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle self._isNovel = False self._isInactive = False if item := SHARED.project.tree[tHandle]: self._isNovel = item.isDocumentLayout() self._isInactive = item.isInactiveClass() logger.debug("Syntax highlighter enabled for item '%s'", tHandle) ## # Methods ## def rehighlightByType(self, cType: int) -> None: """Loop through all blocks and re-highlight those of a given content type. """ if document := self.document(): nBlocks = document.blockCount() tStart = time() for i in range(nBlocks): block = document.findBlockByNumber(i) if block.userState() & cType > 0: self.rehighlightBlock(block) logger.debug("Document highlighted in %.3f ms" % (1000*(time() - tStart))) ## # 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(BLOCK_NONE) if self._tHandle is None or not text: return blockLen = self.currentBlock().length() utf16Map = None if blockLen > len(text) + 1: # If the lengths are different, the line contains 4 byte # Unicode characters, and we must use a map between Python # string indices and the UTF-16 indices used by Qt, where a # 4 byte character occupies two slots. See #2449. utf16Map = utf16CharMap(text) offset = 0 rules = None if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(BLOCK_META) index = SHARED.project.index isValid, bits, loc = index.scanThis(text) isGood = index.checkThese(bits, self._tHandle) if isValid: for n, bit in enumerate(bits): pos = utf16Map[loc[n]] if utf16Map else loc[n] length = utf16Map[loc[n] + len(bit)] - pos if utf16Map else len(bit) if n == 0 and isGood[n]: self.setFormat(pos, length, self._hStyles["keyword"]) elif isGood[n] and not self._isInactive: a, b = index.parseValue(bit) aLen = utf16Map[loc[n] + len(a)] - pos if utf16Map else len(a) self.setFormat(pos, aLen, self._hStyles["tag"]) if b: bLen = utf16Map[loc[n] + len(b)] - pos if utf16Map else len(b) self.setFormat(pos + length - bLen, bLen, self._hStyles["optional"]) elif not self._isInactive: self.setFormat(pos, length, self._hStyles["invalid"]) # We never want to run the spell checker on keyword/values, # so we force a return here return elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")): self.setCurrentBlockState(BLOCK_TITLE) if text.startswith("# "): # Heading 1 self.setFormat(0, 1, self._hStyles["head1h"]) self.setFormat(1, blockLen, self._hStyles["header1"]) elif text.startswith("## "): # Heading 2 self.setFormat(0, 2, self._hStyles["head2h"]) self.setFormat(2, blockLen, self._hStyles["header2"]) elif text.startswith("### "): # Heading 3 self.setFormat(0, 3, self._hStyles["head3h"]) self.setFormat(3, blockLen, self._hStyles["header3"]) elif text.startswith("#### "): # Heading 4 self.setFormat(0, 4, self._hStyles["head4h"]) self.setFormat(4, blockLen, self._hStyles["header4"]) elif text.startswith("#! "): # Title self.setFormat(0, 2, self._hStyles["head1h"]) self.setFormat(2, blockLen, self._hStyles["header1"]) elif text.startswith("##! "): # Unnumbered self.setFormat(0, 3, self._hStyles["head2h"]) self.setFormat(3, blockLen, self._hStyles["header2"]) elif text.startswith("###! "): # Alternative Scene self.setFormat(0, 4, self._hStyles["head3h"]) self.setFormat(4, blockLen, self._hStyles["header3"]) elif text.startswith("%"): # Comments self.setCurrentBlockState(BLOCK_TEXT) rules = self._cmnRules style, mod, _, dot, pos = processComment(text) offset = pos if utf16Map: dot = utf16Map[dot] pos = utf16Map[pos] length = blockLen - pos if style == nwComment.PLAIN: self.setFormat(0, length, self._hStyles["hidden"]) elif style == nwComment.IGNORE: self.setFormat(0, length, self._hStyles["strike"]) return # No more processing for these elif mod: self.setFormat(0, dot, self._hStyles["modifier"]) self.setFormat(dot, pos - dot, self._hStyles["value"]) self.setFormat(pos, length, self._hStyles["note"]) else: self.setFormat(0, pos, self._hStyles["modifier"]) self.setFormat(pos, length, self._hStyles["note"]) elif text.startswith("["): # Special Command self.setCurrentBlockState(BLOCK_TEXT) rules = self._txtRules if self._isNovel else self._minRules check = text.rstrip().lower() if check in ("[newpage]", "[new page]", "[vspace]"): self.setFormat(0, blockLen, self._hStyles["code"]) return elif check.startswith("[vspace:") and check.endswith("]"): value = checkInt(check[8:-1], 0) style = "value" if value > 0 else "invalid" self.setFormat(0, 8, self._hStyles["code"]) self.setFormat(8, blockLen-10, self._hStyles[style]) self.setFormat(blockLen-2, blockLen, self._hStyles["code"]) return else: # Text Paragraph self.setCurrentBlockState(BLOCK_TEXT) rules = self._txtRules if self._isNovel else self._minRules if self._isNovel and self._dialogParser.enabled: if utf16Map: for pos, end in self._dialogParser(text): pos = utf16Map[pos] end = utf16Map[end] self.setFormat(pos, end - pos, self._hStyles["dialog"]) else: for pos, end in self._dialogParser(text): self.setFormat(pos, end - pos, self._hStyles["dialog"]) if rules: if utf16Map: for rX, hRule in rules: for res in re.finditer(rX, text[offset:]): for x, hFmt in hRule.items(): pos = res.start(x) + offset end = res.end(x) + offset for x in range(pos, end): m = utf16Map[x] cFmt = self.format(m) if not cFmt.property(QtTextUserProperty): cFmt.merge(hFmt) self.setFormat(m, utf16Map[x+1] - m, cFmt) else: for rX, hRule in rules: for res in re.finditer(rX, text[offset:]): for x, hFmt in hRule.items(): pos = res.start(x) + offset end = res.end(x) + offset for x in range(pos, end): cFmt = self.format(x) if not cFmt.property(QtTextUserProperty): cFmt.merge(hFmt) self.setFormat(x, 1, cFmt) data = self.currentBlockUserData() if not isinstance(data, TextBlockData): data = TextBlockData() self.setCurrentBlockUserData(data) data.processText(text, offset) if self._spellCheck: for pos, end, _ in data.spellCheck(utf16Map): for x in range(pos, end): cFmt = self.format(x) cFmt.merge(self._spellErr) self.setFormat(x, 1, cFmt) return ## # Internal Functions ## def _addCharFormat( self, name: str, color: QColor | None = None, style: str | None = None, size: float | None = None ) -> None: """Generate a highlighter character format.""" charFormat = QTextCharFormat() blockMerge = name == "markup" charFormat.setProperty(QtTextUserProperty, blockMerge) if style: styles = style.split(",") if "b" in styles: charFormat.setFontWeight(QFont.Weight.Bold) if "i" in styles: charFormat.setFontItalic(True) if "u" in styles: charFormat.setFontUnderline(True) if "s" in styles: charFormat.setFontStrikeOut(True) if "err" in styles: charFormat.setUnderlineColor(SHARED.theme.syntaxTheme.error) charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline) if "bg" in styles and color is not None: charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern)) color = None if color: charFormat.setForeground(color) if size: charFormat.setFontPointSize(round(size*CONFIG.textFont.pointSize())) self._hStyles[name] = charFormat class TextBlockData(QTextBlockUserData): """Custom QTextBlock Data. Custom data stored in a single text block. The spell check state is cached here and used when correcting misspelled text. """ __slots__ = ("_metaData", "_offset", "_spellErrors", "_text") def __init__(self) -> None: super().__init__() self._text = "" self._offset = 0 self._metaData: list[tuple[int, int, str, str]] = [] self._spellErrors: list[tuple[int, int, str]] = [] @property def metaData(self) -> list[tuple[int, int, str, str]]: """Return meta data from last check.""" return self._metaData @property def spellErrors(self) -> list[tuple[int, int, str]]: """Return spell error data from last check.""" return self._spellErrors def processText(self, text: str, offset: int) -> None: """Extract meta data from the text.""" self._metaData = [] if "[" in text: # Strip shortcodes for regEx in [RX_FMT_SC, RX_FMT_SV]: for res in regEx.finditer(text, offset): if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0: pad = " "*(e - s) text = f"{text[:s]}{pad}{text[e:]}" if "http" in text: # Strip URLs for res in RX_URL.finditer(text, offset): if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0: pad = " "*(e - s) text = f"{text[:s]}{pad}{text[e:]}" self._metaData.append((s, e, res.group(0), "url")) self._text = text.replace("\u02bc", "'").replace("_", " ") self._offset = offset def spellCheck(self, utf16Map: list[int] | None) -> list[tuple[int, int, str]]: """Run the spell checker and cache the result, and return the list of spell check errors. """ spell = SHARED.spelling if utf16Map: self._spellErrors = [ (utf16Map[r.start(0)], utf16Map[r.end(0)], w) for r in RX_WORDS.finditer(self._text, self._offset) if (w := r.group(0)) and not (w.isnumeric() or w.isupper() or spell.checkWord(w)) ] else: self._spellErrors = [ (r.start(0), r.end(0), w) for r in RX_WORDS.finditer(self._text, self._offset) if (w := r.group(0)) and not (w.isnumeric() or w.isupper() or spell.checkWord(w)) ] return self._spellErrors