Add a dialogue parser, and horizontal bar auto-complete (#2079)

This commit is contained in:
Veronica Berglyd Olsen
2024-11-01 22:23:16 +01:00
committed by GitHub
16 changed files with 441 additions and 229 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"),
+10 -10
View File
@@ -34,7 +34,7 @@ from PyQt5.QtWidgets import (
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import describeFont, uniqueCompact from novelwriter.common import compact, describeFont, uniqueCompact
from novelwriter.constants import nwUnicode from novelwriter.constants import nwUnicode
from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm
@@ -133,7 +133,7 @@ class GuiPreferences(NDialog):
"""Build the settings form.""" """Build the settings form."""
section = 0 section = 0
iSz = SHARED.theme.baseIconSize iSz = SHARED.theme.baseIconSize
boxFixed = 5*SHARED.theme.textNWidth boxFixed = 6*SHARED.theme.textNWidth
minWidth = CONFIG.pxInt(200) minWidth = CONFIG.pxInt(200)
fontWidth = CONFIG.pxInt(162) fontWidth = CONFIG.pxInt(162)
@@ -568,13 +568,13 @@ class GuiPreferences(NDialog):
) )
self.dialogLine = QLineEdit(self) self.dialogLine = QLineEdit(self)
self.dialogLine.setMaxLength(1) self.dialogLine.setMaxLength(4)
self.dialogLine.setFixedWidth(boxFixed) self.dialogLine.setFixedWidth(boxFixed)
self.dialogLine.setAlignment(QtAlignCenter) self.dialogLine.setAlignment(QtAlignCenter)
self.dialogLine.setText(CONFIG.dialogLine) self.dialogLine.setText(CONFIG.dialogLine)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Dialogue line symbol"), self.dialogLine, self.tr("Dialogue line symbols"), self.dialogLine,
self.tr("Lines starting with this symbol are dialogue.") self.tr("Lines starting with these symbols are always dialogue.")
) )
self.narratorBreak = QLineEdit(self) self.narratorBreak = QLineEdit(self)
@@ -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)
@@ -953,9 +953,9 @@ class GuiPreferences(NDialog):
dialogueStyle = self.dialogStyle.currentData() dialogueStyle = self.dialogStyle.currentData()
allowOpenDial = self.allowOpenDial.isChecked() allowOpenDial = self.allowOpenDial.isChecked()
narratorBreak = self.narratorBreak.text().strip() narratorBreak = self.narratorBreak.text().strip()
dialogueLine = self.dialogLine.text().strip() dialogueLine = uniqueCompact(self.dialogLine.text())
altDialogOpen = self.altDialogOpen.text() altDialogOpen = compact(self.altDialogOpen.text())
altDialogClose = self.altDialogClose.text() altDialogClose = compact(self.altDialogClose.text())
highlightEmph = self.highlightEmph.isChecked() highlightEmph = self.highlightEmph.isChecked()
showMultiSpaces = self.showMultiSpaces.isChecked() showMultiSpaces = self.showMultiSpaces.isChecked()
+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
+120 -93
View File
@@ -36,6 +36,7 @@ import logging
from enum import Enum from enum import Enum
from time import time from time import time
from typing import NamedTuple
from PyQt5.QtCore import ( from PyQt5.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
@@ -83,6 +84,21 @@ class _SelectAction(Enum):
MOVE_AFTER = 3 MOVE_AFTER = 3
class AutoReplaceConfig(NamedTuple):
typPadChar: str
typSQuoteO: str
typSQuoteC: str
typDQuoteO: str
typDQuoteC: str
typRepDQuote: bool
typRepSQuote: bool
typRepDash: bool
typRepDots: bool
typPadBefore: str
typPadAfter: str
class GuiDocEditor(QPlainTextEdit): class GuiDocEditor(QPlainTextEdit):
"""Gui Widget: Main Document Editor""" """Gui Widget: Main Document Editor"""
@@ -128,17 +144,19 @@ class GuiDocEditor(QPlainTextEdit):
self._doReplace = False # Switch to temporarily disable auto-replace self._doReplace = False # Switch to temporarily disable auto-replace
# Typography Cache # Typography Cache
self._typPadChar = " " self._typConf = AutoReplaceConfig(
self._typDQuoteO = '"' typPadChar=" ",
self._typDQuoteC = '"' typSQuoteO="'",
self._typSQuoteO = "'" typSQuoteC="'",
self._typSQuoteC = "'" typDQuoteO='"',
self._typRepDQuote = False typDQuoteC='"',
self._typRepSQuote = False typRepSQuote=False,
self._typRepDash = False typRepDQuote=False,
self._typRepDots = False typRepDash=False,
self._typPadBefore = "" typRepDots=False,
self._typPadAfter = "" typPadBefore="",
typPadAfter="",
)
# Completer # Completer
self._completer = MetaCompleter(self) self._completer = MetaCompleter(self)
@@ -310,21 +328,19 @@ class GuiDocEditor(QPlainTextEdit):
created, and when the user changes the main editor preferences. created, and when the user changes the main editor preferences.
""" """
# Typography # Typography
if CONFIG.fmtPadThin: self._typConf = AutoReplaceConfig(
self._typPadChar = nwUnicode.U_THNBSP typPadChar=nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP,
else: typSQuoteO=CONFIG.fmtSQuoteOpen,
self._typPadChar = nwUnicode.U_NBSP typSQuoteC=CONFIG.fmtSQuoteClose,
typDQuoteO=CONFIG.fmtDQuoteOpen,
self._typSQuoteO = CONFIG.fmtSQuoteOpen typDQuoteC=CONFIG.fmtDQuoteClose,
self._typSQuoteC = CONFIG.fmtSQuoteClose typRepSQuote=CONFIG.doReplaceSQuote,
self._typDQuoteO = CONFIG.fmtDQuoteOpen typRepDQuote=CONFIG.doReplaceDQuote,
self._typDQuoteC = CONFIG.fmtDQuoteClose typRepDash=CONFIG.doReplaceDash,
self._typRepDQuote = CONFIG.doReplaceDQuote typRepDots=CONFIG.doReplaceDots,
self._typRepSQuote = CONFIG.doReplaceSQuote typPadBefore=CONFIG.fmtPadBefore,
self._typRepDash = CONFIG.doReplaceDash typPadAfter=CONFIG.fmtPadAfter,
self._typRepDots = CONFIG.doReplaceDots )
self._typPadBefore = CONFIG.fmtPadBefore
self._typPadAfter = CONFIG.fmtPadAfter
# Reload spell check and dictionaries # Reload spell check and dictionaries
SHARED.updateSpellCheckLanguage() SHARED.updateSpellCheckLanguage()
@@ -737,6 +753,7 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Requesting action: %s", action.name) logger.debug("Requesting action: %s", action.name)
tConf = self._typConf
self._allowAutoReplace(False) self._allowAutoReplace(False)
if action == nwDocAction.UNDO: if action == nwDocAction.UNDO:
self.undo() self.undo()
@@ -755,9 +772,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.MD_STRIKE: elif action == nwDocAction.MD_STRIKE:
self._toggleFormat(2, "~") self._toggleFormat(2, "~")
elif action == nwDocAction.S_QUOTE: elif action == nwDocAction.S_QUOTE:
self._wrapSelection(self._typSQuoteO, self._typSQuoteC) self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.D_QUOTE: elif action == nwDocAction.D_QUOTE:
self._wrapSelection(self._typDQuoteO, self._typDQuoteC) self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.SEL_ALL: elif action == nwDocAction.SEL_ALL:
self._makeSelection(QTextCursor.SelectionType.Document) self._makeSelection(QTextCursor.SelectionType.Document)
elif action == nwDocAction.SEL_PARA: elif action == nwDocAction.SEL_PARA:
@@ -783,9 +800,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.BLOCK_HSC: elif action == nwDocAction.BLOCK_HSC:
self._formatBlock(nwDocAction.BLOCK_HSC) self._formatBlock(nwDocAction.BLOCK_HSC)
elif action == nwDocAction.REPL_SNG: elif action == nwDocAction.REPL_SNG:
self._replaceQuotes("'", self._typSQuoteO, self._typSQuoteC) self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.REPL_DBL: elif action == nwDocAction.REPL_DBL:
self._replaceQuotes("\"", self._typDQuoteO, self._typDQuoteC) self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.RM_BREAKS: elif action == nwDocAction.RM_BREAKS:
self._removeInParLineBreaks() self._removeInParLineBreaks()
elif action == nwDocAction.ALIGN_L: elif action == nwDocAction.ALIGN_L:
@@ -857,13 +874,13 @@ class GuiDocEditor(QPlainTextEdit):
text = insert text = insert
elif isinstance(insert, nwDocInsert): elif isinstance(insert, nwDocInsert):
if insert == nwDocInsert.QUOTE_LS: if insert == nwDocInsert.QUOTE_LS:
text = self._typSQuoteO text = self._typConf.typSQuoteO
elif insert == nwDocInsert.QUOTE_RS: elif insert == nwDocInsert.QUOTE_RS:
text = self._typSQuoteC text = self._typConf.typSQuoteC
elif insert == nwDocInsert.QUOTE_LD: elif insert == nwDocInsert.QUOTE_LD:
text = self._typDQuoteO text = self._typConf.typDQuoteO
elif insert == nwDocInsert.QUOTE_RD: elif insert == nwDocInsert.QUOTE_RD:
text = self._typDQuoteC text = self._typConf.typDQuoteC
elif insert == nwDocInsert.SYNOPSIS: elif insert == nwDocInsert.SYNOPSIS:
text = "%Synopsis: " text = "%Synopsis: "
block = True block = True
@@ -1978,88 +1995,98 @@ class GuiDocEditor(QPlainTextEdit):
if tLen < 1 or tPos-1 > tLen: if tLen < 1 or tPos-1 > tLen:
return return
tOne = text[tPos-1:tPos] t1 = text[tPos-1:tPos]
tTwo = text[tPos-2:tPos] t2 = text[tPos-2:tPos]
tThree = text[tPos-3:tPos] t3 = text[tPos-3:tPos]
t4 = text[tPos-4:tPos]
if not tOne: if not t1:
return return
nDelete = 0 delete = 0
tInsert = tOne insert = t1
tConf = self._typConf
if self._typRepDQuote and tTwo[:1].isspace() and tTwo.endswith('"'): if tConf.typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
nDelete = 1 delete = 1
tInsert = self._typDQuoteO insert = tConf.typDQuoteO
elif self._typRepDQuote and tOne == '"': elif tConf.typRepDQuote and t1 == '"':
nDelete = 1 delete = 1
if tPos == 1: if tPos == 1:
tInsert = self._typDQuoteO insert = tConf.typDQuoteO
elif tPos == 2 and tTwo == '>"': elif tPos == 2 and t2 == '>"':
tInsert = self._typDQuoteO insert = tConf.typDQuoteO
elif tPos == 3 and tThree == '>>"': elif tPos == 3 and t3 == '>>"':
tInsert = self._typDQuoteO insert = tConf.typDQuoteO
else: else:
tInsert = self._typDQuoteC insert = tConf.typDQuoteC
elif self._typRepSQuote and tTwo[:1].isspace() and tTwo.endswith("'"): elif tConf.typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
nDelete = 1 delete = 1
tInsert = self._typSQuoteO insert = tConf.typSQuoteO
elif self._typRepSQuote and tOne == "'": elif tConf.typRepSQuote and t1 == "'":
nDelete = 1 delete = 1
if tPos == 1: if tPos == 1:
tInsert = self._typSQuoteO insert = tConf.typSQuoteO
elif tPos == 2 and tTwo == ">'": elif tPos == 2 and t2 == ">'":
tInsert = self._typSQuoteO insert = tConf.typSQuoteO
elif tPos == 3 and tThree == ">>'": elif tPos == 3 and t3 == ">>'":
tInsert = self._typSQuoteO insert = tConf.typSQuoteO
else: else:
tInsert = self._typSQuoteC insert = tConf.typSQuoteC
elif self._typRepDash and tThree == "---": elif tConf.typRepDash and t4 == "----":
nDelete = 3 delete = 4
tInsert = nwUnicode.U_EMDASH insert = nwUnicode.U_HBAR
elif self._typRepDash and tTwo == "--": elif tConf.typRepDash and t3 == "---":
nDelete = 2 delete = 3
tInsert = nwUnicode.U_ENDASH insert = nwUnicode.U_EMDASH
elif self._typRepDash and tTwo == nwUnicode.U_ENDASH + "-": elif tConf.typRepDash and t2 == "--":
nDelete = 2 delete = 2
tInsert = nwUnicode.U_EMDASH insert = nwUnicode.U_ENDASH
elif self._typRepDots and tThree == "...": elif tConf.typRepDash and t2 == nwUnicode.U_ENDASH + "-":
nDelete = 3 delete = 2
tInsert = nwUnicode.U_HELLIP insert = nwUnicode.U_EMDASH
elif tOne == nwUnicode.U_LSEP: elif tConf.typRepDash and t2 == nwUnicode.U_EMDASH + "-":
delete = 2
insert = nwUnicode.U_HBAR
elif tConf.typRepDots and t3 == "...":
delete = 3
insert = nwUnicode.U_HELLIP
elif t1 == nwUnicode.U_LSEP:
# This resolves issue #1150 # This resolves issue #1150
nDelete = 1 delete = 1
tInsert = nwUnicode.U_PSEP insert = nwUnicode.U_PSEP
tCheck = tInsert check = insert
if self._typPadBefore and tCheck in self._typPadBefore: if tConf.typPadBefore and check in tConf.typPadBefore:
if self._allowSpaceBeforeColon(text, tCheck): if self._allowSpaceBeforeColon(text, check):
nDelete = max(nDelete, 1) delete = max(delete, 1)
chkPos = tPos - nDelete - 1 chkPos = tPos - delete - 1
if chkPos >= 0 and text[chkPos].isspace(): if chkPos >= 0 and text[chkPos].isspace():
# Strip existing space before inserting a new (#1061) # Strip existing space before inserting a new (#1061)
nDelete += 1 delete += 1
tInsert = self._typPadChar + tInsert insert = tConf.typPadChar + insert
if self._typPadAfter and tCheck in self._typPadAfter: if tConf.typPadAfter and check in tConf.typPadAfter:
if self._allowSpaceBeforeColon(text, tCheck): if self._allowSpaceBeforeColon(text, check):
nDelete = max(nDelete, 1) delete = max(delete, 1)
tInsert = tInsert + self._typPadChar insert = insert + tConf.typPadChar
if nDelete > 0: if delete > 0:
cursor.movePosition(QtMoveLeft, QtKeepAnchor, nDelete) cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
cursor.insertText(tInsert) cursor.insertText(insert)
# Re-highlight, since the auto-replace sometimes interferes with it # Re-highlight, since the auto-replace sometimes interferes with it
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block()) self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return return
+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()
+93 -15
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import re import re
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.common import compact, uniqueCompact
from novelwriter.constants import nwRegEx from novelwriter.constants import nwRegEx
@@ -82,26 +83,103 @@ 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.strip()[:1]
if CONFIG.dialogStyle in (2, 3): symC += CONFIG.fmtSQuoteClose.strip()[:1]
symO += CONFIG.fmtDQuoteOpen if CONFIG.dialogStyle in (2, 3):
symC += CONFIG.fmtDQuoteClose symO += CONFIG.fmtDQuoteOpen.strip()[:1]
symC += CONFIG.fmtDQuoteClose.strip()[:1]
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(compact(CONFIG.altDialogOpen))
return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE) symC = re.escape(compact(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 = uniqueCompact(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
return result
@@ -1,8 +1,8 @@
%%~name: New Scene %%~name: New Scene
%%~path: 000000000000d/000000000000f %%~path: 000000000000d/000000000000f
%%~kind: NOVEL/DOCUMENT %%~kind: NOVEL/DOCUMENT
%%~hash: 89b54ddaec2fddfd220e91dd438c84fb3aef9fc2 %%~hash: e4148ea77e78c90c334d5dc46c38a2b7904ac117
%%~date: 2024-10-30 00:07:21/2024-10-30 00:07:26 %%~date: 2024-11-01 21:15:57/2024-11-01 21:16:01
# Novel # Novel
## Chapter ## Chapter
@@ -26,7 +26,7 @@ This is a paragraph of nonsense text.
This is another paragraph This is another paragraph
with a line separator in it. with a line separator in it.
This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! We can also try replacing “quotes”, even single quotes are replaced. Isnt that nice? We can hyphen-ate, make dashes and even longer dashes — if we want. Ellipsis? Not a problem either … How about three hyphens — for long dash? It works too. This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! We can also try replacing “quotes”, even single quotes are replaced. Isnt that nice? We can hyphen-ate, make dashes and even longer dashes — if we want. We can even go on to a ― hotizontal bar. Ellipsis? Not a problem either … How about three hyphens — for long dash? It works too. Even four hyphens ― for a horizontal works!
“Full line double quoted text.” “Full line double quoted text.”
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.6a3" hexVersion="0x020600a3" fileVersion="1.5" fileRevision="4" timeStamp="2024-10-30 00:06:45"> <novelWriterXML appVersion="2.6a3" hexVersion="0x020600a3" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-01 21:15:11">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="5"> <project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="5">
<name>New Project</name> <name>New Project</name>
<author>Jane Doe</author> <author>Jane Doe</author>
@@ -28,7 +28,7 @@
<entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry> <entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance> </importance>
</settings> </settings>
<content items="11" novelWords="161" notesWords="27"> <content items="11" novelWords="179" notesWords="27">
<item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL"> <item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="yes" /> <meta expanded="yes" />
<name status="s000000" import="i000004">Novel</name> <name status="s000000" import="i000004">Novel</name>
@@ -46,7 +46,7 @@
<name status="s000000" import="i000004" active="yes">New Chapter</name> <name status="s000000" import="i000004" active="yes">New Chapter</name>
</item> </item>
<item handle="000000000000f" parent="000000000000d" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT"> <item handle="000000000000f" parent="000000000000d" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H1" charCount="918" wordCount="154" paraCount="17" cursorPos="1174" /> <meta expanded="no" heading="H1" charCount="1003" wordCount="172" paraCount="17" cursorPos="1259" />
<name status="s000000" import="i000004" active="yes">New Scene</name> <name status="s000000" import="i000004" active="yes">New Scene</name>
</item> </item>
<item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT"> <item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT">
+27 -10
View File
@@ -143,7 +143,6 @@ def testFmtToHtml_ConvertParagraphs(mockGUI):
project = NWProject() project = NWProject()
html = ToHtml(project) html = ToHtml(project)
html.initDocument() html.initDocument()
html._isNovel = True html._isNovel = True
html._isFirst = True html._isFirst = True
@@ -279,20 +278,31 @@ def testFmtToHtml_ConvertParagraphs(mockGUI):
"<span style='color: #4271ae'><a href='#tag_europe'>Europe</a></span></p>\n" "<span style='color: #4271ae'><a href='#tag_europe'>Europe</a></span></p>\n"
) )
# Dialogue
html.setDialogueHighlight(True) @pytest.mark.core
def testFmtToHtml_Dialog(mockGUI):
"""Test paragraph formats in the ToHtml class."""
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
project = NWProject()
html = ToHtml(project)
html.initDocument()
html.setDialogHighlight(True)
html._isNovel = True
html._isFirst = True
# Dialog
html.setDialogHighlight(True)
html._text = "## Chapter\n\nThis text \u201chas dialogue\u201d in it.\n\n" html._text = "## Chapter\n\nThis text \u201chas dialogue\u201d in it.\n\n"
html.tokenizeText() html.tokenizeText()
html.doConvert() html.doConvert()
assert html._pages[-1] == ( assert html._pages[-1] == (
"<h1 style='page-break-before: always;'>Chapter</h1>\n" "<h1>Chapter</h1>\n"
"<p>This text <span style='color: #4271ae'>“has dialogue”</span> in it.</p>\n" "<p>This text <span style='color: #4271ae'>“has dialogue”</span> in it.</p>\n"
) )
# Alt. Dialogue # Alt Dialog
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
html.setDialogueHighlight(True)
html._text = "## Chapter\n\nThis text ::has alt dialogue:: in it.\n\n" html._text = "## Chapter\n\nThis text ::has alt dialogue:: in it.\n\n"
html.tokenizeText() html.tokenizeText()
html.doConvert() html.doConvert()
@@ -301,8 +311,15 @@ def testFmtToHtml_ConvertParagraphs(mockGUI):
"<p>This text <span style='color: #813709'>::has alt dialogue::</span> in it.</p>\n" "<p>This text <span style='color: #813709'>::has alt dialogue::</span> in it.</p>\n"
) )
# Footnotes
# ========= @pytest.mark.core
def testFmtToHtml_Footnotes(mockGUI):
"""Test paragraph formats in the ToHtml class."""
project = NWProject()
html = ToHtml(project)
html.initDocument()
html._isNovel = True
html._isFirst = True
html._text = ( html._text = (
"Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n" "Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n"
+12 -6
View File
@@ -1286,7 +1286,7 @@ def testFmtToken_Dialogue(mockGUI):
project = NWProject() project = NWProject()
tokens = BareTokenizer(project) tokens = BareTokenizer(project)
tokens.setDialogueHighlight(True) tokens.setDialogHighlight(True)
tokens._handle = TMH tokens._handle = TMH
tokens._isNovel = True tokens._isNovel = True
@@ -1337,7 +1337,10 @@ def testFmtToken_Dialogue(mockGUI):
# Dialogue line # Dialogue line
CONFIG.dialogLine = "\u2013" CONFIG.dialogLine = "\u2013"
tokens.setDialogueHighlight(True) tokens = BareTokenizer(project)
tokens.setDialogHighlight(True)
tokens._handle = TMH
tokens._isNovel = True
tokens._text = "\u2013 Dialogue line without narrator break.\n" tokens._text = "\u2013 Dialogue line without narrator break.\n"
tokens.tokenizeText() tokens.tokenizeText()
assert tokens._blocks == [( assert tokens._blocks == [(
@@ -1352,16 +1355,19 @@ def testFmtToken_Dialogue(mockGUI):
# Dialogue line with narrator break # Dialogue line with narrator break
CONFIG.narratorBreak = "\u2013" CONFIG.narratorBreak = "\u2013"
tokens.setDialogueHighlight(True) tokens = BareTokenizer(project)
tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?\n" tokens.setDialogHighlight(True)
tokens._handle = TMH
tokens._isNovel = True
tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said\u2013, see?\n"
tokens.tokenizeText() tokens.tokenizeText()
assert tokens._blocks == [( assert tokens._blocks == [(
BlockTyp.TEXT, "", BlockTyp.TEXT, "",
"\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?", "\u2013 Dialogue with a narrator break, \u2013he said\u2013, see?",
[ [
(0, TextFmt.COL_B, "dialog"), (0, TextFmt.COL_B, "dialog"),
(34, TextFmt.COL_E, ""), (34, TextFmt.COL_E, ""),
(43, TextFmt.COL_B, "dialog"), (44, TextFmt.COL_B, "dialog"),
(49, TextFmt.COL_E, ""), (49, TextFmt.COL_E, ""),
], ],
BlockFmt.NONE BlockFmt.NONE
+1 -1
View File
@@ -263,7 +263,7 @@ def testFmtToOdt_DialogueFormatting(mockGUI):
"""Test formatting of dialogue.""" """Test formatting of dialogue."""
project = NWProject() project = NWProject()
odt = ToOdt(project, isFlat=True) odt = ToOdt(project, isFlat=True)
odt.setDialogueHighlight(True) odt.setDialogHighlight(True)
odt.initDocument() odt.initDocument()
oStyle = ODTParagraphStyle("test") oStyle = ODTParagraphStyle("test")
+1 -1
View File
@@ -442,7 +442,7 @@ def testFmtToQTextDocument_TextCharFormats(mockGUI):
# Convert before init # Convert before init
doc._text = "Blabla" doc._text = "Blabla"
doc.setDialogueHighlight(True) doc.setDialogHighlight(True)
doc.doConvert() doc.doConvert()
doc.tokenizeText() doc.tokenizeText()
assert doc.document.toPlainText() == "" assert doc.document.toPlainText() == ""
+2 -2
View File
@@ -80,7 +80,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert qDoc.defaultTextOption().alignment() == QtAlignLeft assert qDoc.defaultTextOption().alignment() == QtAlignLeft
assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor._typPadChar == nwUnicode.U_NBSP assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP
assert docEditor.docHeader.itemTitle.text() == ( assert docEditor.docHeader.itemTitle.text() == (
"Novel \u203a New Chapter \u203a New Scene" "Novel \u203a New Chapter \u203a New Scene"
) )
@@ -105,7 +105,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators
assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff
assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff
assert docEditor._typPadChar == nwUnicode.U_THNBSP assert docEditor._typConf.typPadChar == nwUnicode.U_THNBSP
assert docEditor.docHeader.itemTitle.text() == "New Scene" assert docEditor.docHeader.itemTitle.text() == "New Scene"
# Header # Header
+23 -7
View File
@@ -421,6 +421,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ": for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
for c in "We can even go on to a ---- hotizontal bar. ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
for c in "Ellipsis? Not a problem either ... ": for c in "Ellipsis? Not a problem either ... ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
for c in "How about three hyphens - -": for c in "How about three hyphens - -":
@@ -428,7 +430,17 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY)
for c in "- for long dash? It works too.": for c in "- for long dash? It works too. ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
for c in "Even four hyphens - - -":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY)
for c in "- for a horizontal works!":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
@@ -447,16 +459,18 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
# Insert spaces before and after quotes # Insert spaces before and after quotes
docEditor._typPadBefore = "\u201d" CONFIG.fmtPadBefore = "\u201d"
docEditor._typPadAfter = "\u201c" CONFIG.fmtPadAfter = "\u201c"
docEditor.initEditor()
for c in "Some \"double quoted text with spaces padded\".": for c in "Some \"double quoted text with spaces padded\".":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
docEditor._typPadBefore = "" CONFIG.fmtPadBefore = ""
docEditor._typPadAfter = "" CONFIG.fmtPadAfter = ""
docEditor.initEditor()
# Dialogue Line # Dialogue Line
for c in "-- Hi, I am a character speaking.": for c in "-- Hi, I am a character speaking.":
@@ -478,7 +492,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# ================== # ==================
# Insert spaces before colon, but ignore tags # Insert spaces before colon, but ignore tags
docEditor._typPadBefore = ":" CONFIG.fmtPadBefore = ":"
docEditor.initEditor()
for c in "@object: NoSpaceAdded": for c in "@object: NoSpaceAdded":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
@@ -505,7 +520,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
docEditor._typPadBefore = "" CONFIG.fmtPadBefore = ""
docEditor.initEditor()
# Indent and Align # Indent and Align
# ================ # ================
+116 -7
View File
@@ -26,7 +26,7 @@ import pytest
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.constants import nwUnicode from novelwriter.constants import nwUnicode
from novelwriter.text.patterns import REGEX_PATTERNS from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
def allMatches(regEx: re.Pattern, text: str) -> list[list[str]]: def allMatches(regEx: re.Pattern, text: str) -> list[list[str]]:
@@ -267,6 +267,10 @@ def testTextPatterns_ShortcodesValue():
@pytest.mark.core @pytest.mark.core
def testTextPatterns_DialogueStyle(): def testTextPatterns_DialogueStyle():
"""Test the dialogue style pattern regexes.""" """Test the dialogue style pattern regexes."""
# Before set, the regex is None
CONFIG.dialogStyle = 0
assert REGEX_PATTERNS.dialogStyle is None
# Set the config # Set the config
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
@@ -280,6 +284,7 @@ def testTextPatterns_DialogueStyle():
CONFIG.allowOpenDial = False CONFIG.allowOpenDial = False
regEx = REGEX_PATTERNS.dialogStyle regEx = REGEX_PATTERNS.dialogStyle
assert regEx is not None
# Defined single quotes are recognised # Defined single quotes are recognised
assert allMatches(regEx, "one \u2018two\u2019 three") == [ assert allMatches(regEx, "one \u2018two\u2019 three") == [
@@ -305,6 +310,7 @@ def testTextPatterns_DialogueStyle():
CONFIG.allowOpenDial = True CONFIG.allowOpenDial = True
regEx = REGEX_PATTERNS.dialogStyle regEx = REGEX_PATTERNS.dialogStyle
assert regEx is not None
# Defined single quotes are recognised also when open # Defined single quotes are recognised also when open
assert allMatches(regEx, "one \u2018two three") == [ assert allMatches(regEx, "one \u2018two three") == [
@@ -320,19 +326,19 @@ def testTextPatterns_DialogueStyle():
@pytest.mark.core @pytest.mark.core
def testTextPatterns_DialogueSpecial(): def testTextPatterns_DialogueSpecial():
"""Test the special dialogue style pattern regexes.""" """Test the special dialogue style pattern regexes."""
# Set the config # Before set, the regex is None
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO CONFIG.altDialogOpen = ""
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO CONFIG.altDialogClose = ""
CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO assert REGEX_PATTERNS.altDialogStyle is None
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO
CONFIG.dialogStyle = 3 # Set the config
CONFIG.altDialogOpen = "::" CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::" CONFIG.altDialogClose = "::"
# Alternative Dialogue # Alternative Dialogue
# ==================== # ====================
regEx = REGEX_PATTERNS.altDialogStyle regEx = REGEX_PATTERNS.altDialogStyle
assert regEx is not None
# With no padding # With no padding
assert allMatches(regEx, "one ::two:: three") == [ assert allMatches(regEx, "one ::two:: three") == [
@@ -343,3 +349,106 @@ def testTextPatterns_DialogueSpecial():
assert allMatches(regEx, "one :: two :: three") == [ assert allMatches(regEx, "one :: two :: three") == [
[(":: two ::", 4, 13)] [(":: two ::", 4, 13)]
] ]
@pytest.mark.core
def testTextPatterns_DialogParserEnglish():
"""Test the dialog parser with English settings."""
# Set the config
CONFIG.dialogStyle = 3
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO
parser = DialogParser()
parser.initParser()
# Positions: 0 18
assert parser("“Simple dialogue.”") == [
(0, 18),
]
# Positions: 0 18
assert parser("“Simple dialogue,” argued John.") == [
(0, 18),
]
# Positions: 0 18 32 56
assert parser("“Simple dialogue,” argued John, “is not always so easy.”") == [
(0, 18), (32, 56),
]
# With Narrator breaks
CONFIG.dialogLine = ""
CONFIG.narratorBreak = nwUnicode.U_EMDASH
parser.initParser()
# Positions: 0 18 34 58
assert parser("“Simple dialogue, — argued John, — is not always so easy.”") == [
(0, 18), (34, 58),
]
@pytest.mark.core
def testTextPatterns_DialogParserSpanish():
"""Test the dialog parser with Spanish settings."""
# Set the config
CONFIG.dialogStyle = 3
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSAQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSAQUO
CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO
CONFIG.dialogLine = nwUnicode.U_EMDASH + nwUnicode.U_RAQUO
CONFIG.narratorBreak = nwUnicode.U_EMDASH
parser = DialogParser()
parser.initParser()
# Positions: 0 18 54 70
assert parser("—No te preocupes. —Cerró la puerta y salió corriendo—. Volveré pronto.") == [
(0, 18), (54, 70),
]
# Positions: 0 14
assert parser("«Tengo hambre», pensó Pedro.") == [
(0, 14),
]
# Positions: 0 16
assert parser("—Puedes hacerlo —le dije y pensé «pero te costará mucho trabajo».") == [
(0, 16),
]
@pytest.mark.core
def testTextPatterns_DialogParserAlternating():
"""Test the dialog parser with alternating dialogue/narration like
for Portuguese and Polish.
"""
# Set the config
CONFIG.dialogStyle = 0
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSAQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSAQUO
CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO
CONFIG.dialogLine = ""
CONFIG.narratorBreak = nwUnicode.U_EMDASH
parser = DialogParser()
parser.initParser()
# Positions: 0 21
assert parser("— Está ficando tarde.") == [
(0, 21),
]
# Positions: 0 12
assert parser("— Ainda não — ela responde.") == [
(0, 12),
]
# Positions: 0 12 28 49
assert parser("— Tudo bem? — ele pergunta. — Você falou com ele?") == [
(0, 12), (28, 49),
]