Add a dialog parser class

This commit is contained in:
Veronica Berglyd Olsen
2024-11-01 19:55:35 +01:00
parent 057fc10565
commit 31c75ea0b1
6 changed files with 129 additions and 88 deletions
+1 -1
View File
@@ -272,7 +272,7 @@ class NWBuildDocument:
bldObj.setJustify(self._build.getBool("format.justifyText")) bldObj.setJustify(self._build.getBool("format.justifyText"))
bldObj.setLineHeight(self._build.getFloat("format.lineHeight")) bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks")) bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue")) bldObj.setDialogHighlight(self._build.getBool("format.showDialogue"))
bldObj.setFirstLineIndent( bldObj.setFirstLineIndent(
self._build.getBool("format.firstLineIndent"), self._build.getBool("format.firstLineIndent"),
self._build.getFloat("format.firstIndentWidth"), self._build.getFloat("format.firstIndentWidth"),
+2 -2
View File
@@ -583,8 +583,8 @@ class GuiPreferences(NDialog):
self.narratorBreak.setAlignment(QtAlignCenter) self.narratorBreak.setAlignment(QtAlignCenter)
self.narratorBreak.setText(CONFIG.narratorBreak) self.narratorBreak.setText(CONFIG.narratorBreak)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Dialogue narrator break symbol"), self.narratorBreak, self.tr("Alternating dialogue/narration symbol"), self.narratorBreak,
self.tr("Symbol to indicate injected narrator break.") self.tr("Alternates dialogue highlighting within a paragraph.")
) )
self.highlightEmph = NSwitch(self) self.highlightEmph = NSwitch(self)
+18 -41
View File
@@ -46,7 +46,7 @@ from novelwriter.enum import nwComment, nwItemLayout
from novelwriter.formats.shared import ( from novelwriter.formats.shared import (
BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt
) )
from novelwriter.text.patterns import REGEX_PATTERNS from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -199,9 +199,10 @@ class Tokenizer(ABC):
} }
# Dialogue # Dialogue
self._rxDialogue: list[tuple[re.Pattern, tuple[int, str], tuple[int, str]]] = [] self._hlightDialog = False
self._dialogLine = "" self._rxAltDialog = REGEX_PATTERNS.altDialogStyle
self._narratorBreak = "" self._dialogParser = DialogParser()
self._dialogParser.initParser()
return return
@@ -335,22 +336,9 @@ class Tokenizer(ABC):
self._doJustify = state self._doJustify = state
return return
def setDialogueHighlight(self, state: bool) -> None: def setDialogHighlight(self, state: bool) -> None:
"""Enable or disable dialogue highlighting.""" """Enable or disable dialogue highlighting."""
self._rxDialogue = [] self._hlightDialog = state
if state:
if CONFIG.dialogStyle > 0:
self._rxDialogue.append((
REGEX_PATTERNS.dialogStyle,
(TextFmt.COL_B, "dialog"), (TextFmt.COL_E, ""),
))
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
self._rxDialogue.append((
REGEX_PATTERNS.altDialogStyle,
(TextFmt.COL_B, "altdialog"), (TextFmt.COL_E, ""),
))
self._dialogLine = CONFIG.dialogLine.strip()[:1]
self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
return return
def setTitleMargins(self, upper: float, lower: float) -> None: def setTitleMargins(self, upper: float, lower: float) -> None:
@@ -1132,10 +1120,8 @@ class Tokenizer(ABC):
# Match URLs # Match URLs
for res in REGEX_PATTERNS.url.finditer(text): for res in REGEX_PATTERNS.url.finditer(text):
s = res.start(0) temp.append((res.start(0), 0, TextFmt.HRF_B, res.group(0)))
e = res.end(0) temp.append((res.end(0), 0, TextFmt.HRF_E, ""))
temp.append((s, s, TextFmt.HRF_B, res.group(0)))
temp.append((e, e, TextFmt.HRF_E, ""))
# Match Shortcodes # Match Shortcodes
for res in REGEX_PATTERNS.shortcodePlain.finditer(text): for res in REGEX_PATTERNS.shortcodePlain.finditer(text):
@@ -1156,24 +1142,15 @@ class Tokenizer(ABC):
)) ))
# Match Dialogue # Match Dialogue
if self._rxDialogue and hDialog: if self._hlightDialog and hDialog:
for regEx, (fmtB, clsB), (fmtE, clsE) in self._rxDialogue: if self._dialogParser.enabled:
for res in regEx.finditer(text): for pos, end in self._dialogParser(text):
temp.append((res.start(0), 0, fmtB, clsB)) temp.append((pos, 0, TextFmt.COL_B, "dialog"))
temp.append((res.end(0), 0, fmtE, clsE)) temp.append((end, 0, TextFmt.COL_E, ""))
if self._rxAltDialog:
if self._dialogLine and text.startswith(self._dialogLine): for res in self._rxAltDialog.finditer(text):
if self._narratorBreak: temp.append((res.start(0), 0, TextFmt.COL_B, "altdialog"))
pos = 0 temp.append((res.end(0), 0, TextFmt.COL_E, ""))
for num, bit in enumerate(text[1:].split(self._narratorBreak), 1):
length = len(bit) + 1
if num%2:
temp.append((pos, 0, TextFmt.COL_B, "dialog"))
temp.append((pos + length, 0, TextFmt.COL_E, ""))
pos += length
else:
temp.append((0, 0, TextFmt.COL_B, "dialog"))
temp.append((len(text), 0, TextFmt.COL_E, ""))
# Post-process text and format # Post-process text and format
result = text result = text
+10 -28
View File
@@ -40,7 +40,7 @@ from novelwriter.common import checkInt
from novelwriter.constants import nwStyles, nwUnicode from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.core.index import processComment from novelwriter.core.index import processComment
from novelwriter.enum import nwComment from novelwriter.enum import nwComment
from novelwriter.text.patterns import REGEX_PATTERNS from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,8 +59,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = ( __slots__ = (
"_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr", "_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr",
"_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogLine", "_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogParser",
"_narratorBreak",
) )
def __init__(self, document: QTextDocument) -> None: def __init__(self, document: QTextDocument) -> None:
@@ -79,8 +78,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules: 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._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
self._dialogLine = "" self._dialogParser = DialogParser()
self._narratorBreak = ""
self.initHighlighter() self.initHighlighter()
@@ -136,8 +134,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.clear() self._txtRules.clear()
self._cmnRules.clear() self._cmnRules.clear()
self._dialogLine = CONFIG.dialogLine.strip()[:1] self._dialogParser.initParser()
self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
# Multiple or Trailing Spaces # Multiple or Trailing Spaces
if CONFIG.showMultiSpaces: if CONFIG.showMultiSpaces:
@@ -158,16 +155,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule))
# Dialogue # Alt Dialogue
if CONFIG.dialogStyle > 0: if rxRule := REGEX_PATTERNS.altDialogStyle:
rxRule = REGEX_PATTERNS.dialogStyle
hlRule = {
0: self._hStyles["dialog"],
}
self._txtRules.append((rxRule, hlRule))
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
rxRule = REGEX_PATTERNS.altDialogStyle
hlRule = { hlRule = {
0: self._hStyles["altdialog"], 0: self._hStyles["altdialog"],
} }
@@ -403,17 +392,10 @@ class GuiDocHighlighter(QSyntaxHighlighter):
else: # Text Paragraph else: # Text Paragraph
self.setCurrentBlockState(BLOCK_TEXT) self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules hRules = self._txtRules if self._isNovel else self._minRules
if self._dialogParser.enabled:
if self._dialogLine and text.startswith(self._dialogLine): for pos, end in self._dialogParser(text):
if self._narratorBreak: length = end - pos
tPos = 0 self.setFormat(pos, length, self._hStyles["dialog"])
for tNum, tBit in enumerate(text[1:].split(self._narratorBreak), 1):
tLen = len(tBit) + 1
if tNum%2:
self.setFormat(tPos, tLen, self._hStyles["dialog"])
tPos += tLen
else:
self.setFormat(0, len(text), self._hStyles["dialog"])
if hRules: if hRules:
for rX, hRule in hRules: for rX, hRule in hRules:
+1 -1
View File
@@ -214,7 +214,7 @@ class GuiDocViewer(QTextBrowser):
sPos = self.verticalScrollBar().value() sPos = self.verticalScrollBar().value()
qDoc = ToQTextDocument(SHARED.project) qDoc = ToQTextDocument(SHARED.project)
qDoc.setJustify(CONFIG.doJustify) qDoc.setJustify(CONFIG.doJustify)
qDoc.setDialogueHighlight(True) qDoc.setDialogHighlight(True)
qDoc.setFont(CONFIG.textFont) qDoc.setFont(CONFIG.textFont)
qDoc.setTheme(self._docTheme) qDoc.setTheme(self._docTheme)
qDoc.initDocument() qDoc.initDocument()
+97 -15
View File
@@ -82,26 +82,108 @@ class RegExPatterns:
return self._rxSCValue return self._rxSCValue
@property @property
def dialogStyle(self) -> re.Pattern: def dialogStyle(self) -> re.Pattern | None:
"""Dialogue detection rule based on user settings.""" """Dialogue detection rule based on user settings."""
symO = "" if CONFIG.dialogStyle > 0:
symC = "" symO = ""
if CONFIG.dialogStyle in (1, 3): symC = ""
symO += CONFIG.fmtSQuoteOpen if CONFIG.dialogStyle in (1, 3):
symC += CONFIG.fmtSQuoteClose symO += CONFIG.fmtSQuoteOpen
if CONFIG.dialogStyle in (2, 3): symC += CONFIG.fmtSQuoteClose
symO += CONFIG.fmtDQuoteOpen if CONFIG.dialogStyle in (2, 3):
symC += CONFIG.fmtDQuoteClose symO += CONFIG.fmtDQuoteOpen
symC += CONFIG.fmtDQuoteClose
rxEnd = "|$" if CONFIG.allowOpenDial else "" rxEnd = "|$" if CONFIG.allowOpenDial else ""
return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE) return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)
return None
@property @property
def altDialogStyle(self) -> re.Pattern: def altDialogStyle(self) -> re.Pattern | None:
"""Dialogue alternative rule based on user settings.""" """Dialogue alternative rule based on user settings."""
symO = re.escape(CONFIG.altDialogOpen) if CONFIG.altDialogOpen and CONFIG.altDialogClose:
symC = re.escape(CONFIG.altDialogClose) symO = re.escape(CONFIG.altDialogOpen)
return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE) symC = re.escape(CONFIG.altDialogClose)
return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE)
return None
REGEX_PATTERNS = RegExPatterns() REGEX_PATTERNS = RegExPatterns()
class DialogParser:
__slots__ = ("_quotes", "_dialog", "_narrator", "_break", "_enabled")
def __init__(self) -> None:
self._quotes = None
self._dialog = ""
self._narrator = ""
self._break = re.compile("")
self._enabled = False
return
@property
def enabled(self) -> bool:
"""Return True if there are any settings to parse."""
return self._enabled
def initParser(self) -> None:
"""Init parser settings. Must be called when config changes."""
punct = re.escape("!?.,:;")
self._quotes = REGEX_PATTERNS.dialogStyle
self._dialog = CONFIG.dialogLine
self._narrator = CONFIG.narratorBreak.strip()[:1]
self._break = re.compile(
f"({self._narrator}\\s?.*?\\s?(?:{self._narrator}[{punct}]?|$))", re.UNICODE
)
self._enabled = bool(self._quotes or self._dialog or self._narrator)
return
def __call__(self, text: str) -> list[tuple[int, int]]:
"""Caller wrapper for dialogue processing."""
temp: list[int] = []
if text:
plain = True
if self._dialog and text[0] in self._dialog:
plain = False
temp.append(0)
temp.append(len(text))
if self._narrator:
for res in self._break.finditer(text, 1):
temp.append(res.start(0))
temp.append(res.end(0))
elif self._quotes:
for res in self._quotes.finditer(text):
plain = False
temp.append(res.start(0))
temp.append(res.end(0))
if self._narrator:
for sub in self._break.finditer(text, res.start(0), res.end(0)):
temp.append(sub.start(0))
temp.append(sub.end(0))
if plain and self._narrator:
pos = 0
for num, bit in enumerate(text.split(self._narrator)):
length = len(bit) + int(num > 0)
if num%2:
temp.append(pos)
temp.append(pos + length)
pos += length
start = None
result = []
for pos in sorted(set(temp)):
if start is None:
start = pos
else:
result.append((start, pos))
start = None
# print("-"*80)
# print(f"'{text}'")
# print(temp)
# print(result)
return result