Files
novelWriter/novelwriter/gui/dochighlight.py
T
2025-10-26 12:38:32 +01:00

568 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 <https://www.gnu.org/licenses/>.
""" # 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