Add a dialog parser class
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user