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.setLineHeight(self._build.getFloat("format.lineHeight"))
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
bldObj.setDialogHighlight(self._build.getBool("format.showDialogue"))
bldObj.setFirstLineIndent(
self._build.getBool("format.firstLineIndent"),
self._build.getFloat("format.firstIndentWidth"),
+10 -10
View File
@@ -34,7 +34,7 @@ from PyQt5.QtWidgets import (
)
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.dialogs.quotes import GuiQuoteSelect
from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm
@@ -133,7 +133,7 @@ class GuiPreferences(NDialog):
"""Build the settings form."""
section = 0
iSz = SHARED.theme.baseIconSize
boxFixed = 5*SHARED.theme.textNWidth
boxFixed = 6*SHARED.theme.textNWidth
minWidth = CONFIG.pxInt(200)
fontWidth = CONFIG.pxInt(162)
@@ -568,13 +568,13 @@ class GuiPreferences(NDialog):
)
self.dialogLine = QLineEdit(self)
self.dialogLine.setMaxLength(1)
self.dialogLine.setMaxLength(4)
self.dialogLine.setFixedWidth(boxFixed)
self.dialogLine.setAlignment(QtAlignCenter)
self.dialogLine.setText(CONFIG.dialogLine)
self.mainForm.addRow(
self.tr("Dialogue line symbol"), self.dialogLine,
self.tr("Lines starting with this symbol are dialogue.")
self.tr("Dialogue line symbols"), self.dialogLine,
self.tr("Lines starting with these symbols are always dialogue.")
)
self.narratorBreak = QLineEdit(self)
@@ -583,8 +583,8 @@ class GuiPreferences(NDialog):
self.narratorBreak.setAlignment(QtAlignCenter)
self.narratorBreak.setText(CONFIG.narratorBreak)
self.mainForm.addRow(
self.tr("Dialogue narrator break symbol"), self.narratorBreak,
self.tr("Symbol to indicate injected narrator break.")
self.tr("Alternating dialogue/narration symbol"), self.narratorBreak,
self.tr("Alternates dialogue highlighting within a paragraph.")
)
self.highlightEmph = NSwitch(self)
@@ -953,9 +953,9 @@ class GuiPreferences(NDialog):
dialogueStyle = self.dialogStyle.currentData()
allowOpenDial = self.allowOpenDial.isChecked()
narratorBreak = self.narratorBreak.text().strip()
dialogueLine = self.dialogLine.text().strip()
altDialogOpen = self.altDialogOpen.text()
altDialogClose = self.altDialogClose.text()
dialogueLine = uniqueCompact(self.dialogLine.text())
altDialogOpen = compact(self.altDialogOpen.text())
altDialogClose = compact(self.altDialogClose.text())
highlightEmph = self.highlightEmph.isChecked()
showMultiSpaces = self.showMultiSpaces.isChecked()
+18 -41
View File
@@ -46,7 +46,7 @@ from novelwriter.enum import nwComment, nwItemLayout
from novelwriter.formats.shared import (
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__)
@@ -199,9 +199,10 @@ class Tokenizer(ABC):
}
# Dialogue
self._rxDialogue: list[tuple[re.Pattern, tuple[int, str], tuple[int, str]]] = []
self._dialogLine = ""
self._narratorBreak = ""
self._hlightDialog = False
self._rxAltDialog = REGEX_PATTERNS.altDialogStyle
self._dialogParser = DialogParser()
self._dialogParser.initParser()
return
@@ -335,22 +336,9 @@ class Tokenizer(ABC):
self._doJustify = state
return
def setDialogueHighlight(self, state: bool) -> None:
def setDialogHighlight(self, state: bool) -> None:
"""Enable or disable dialogue highlighting."""
self._rxDialogue = []
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]
self._hlightDialog = state
return
def setTitleMargins(self, upper: float, lower: float) -> None:
@@ -1132,10 +1120,8 @@ class Tokenizer(ABC):
# Match URLs
for res in REGEX_PATTERNS.url.finditer(text):
s = res.start(0)
e = res.end(0)
temp.append((s, s, TextFmt.HRF_B, res.group(0)))
temp.append((e, e, TextFmt.HRF_E, ""))
temp.append((res.start(0), 0, TextFmt.HRF_B, res.group(0)))
temp.append((res.end(0), 0, TextFmt.HRF_E, ""))
# Match Shortcodes
for res in REGEX_PATTERNS.shortcodePlain.finditer(text):
@@ -1156,24 +1142,15 @@ class Tokenizer(ABC):
))
# Match Dialogue
if self._rxDialogue and hDialog:
for regEx, (fmtB, clsB), (fmtE, clsE) in self._rxDialogue:
for res in regEx.finditer(text):
temp.append((res.start(0), 0, fmtB, clsB))
temp.append((res.end(0), 0, fmtE, clsE))
if self._dialogLine and text.startswith(self._dialogLine):
if self._narratorBreak:
pos = 0
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, ""))
if self._hlightDialog and hDialog:
if self._dialogParser.enabled:
for pos, end in self._dialogParser(text):
temp.append((pos, 0, TextFmt.COL_B, "dialog"))
temp.append((end, 0, TextFmt.COL_E, ""))
if self._rxAltDialog:
for res in self._rxAltDialog.finditer(text):
temp.append((res.start(0), 0, TextFmt.COL_B, "altdialog"))
temp.append((res.end(0), 0, TextFmt.COL_E, ""))
# Post-process text and format
result = text
+120 -93
View File
@@ -36,6 +36,7 @@ import logging
from enum import Enum
from time import time
from typing import NamedTuple
from PyQt5.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
@@ -83,6 +84,21 @@ class _SelectAction(Enum):
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):
"""Gui Widget: Main Document Editor"""
@@ -128,17 +144,19 @@ class GuiDocEditor(QPlainTextEdit):
self._doReplace = False # Switch to temporarily disable auto-replace
# Typography Cache
self._typPadChar = " "
self._typDQuoteO = '"'
self._typDQuoteC = '"'
self._typSQuoteO = "'"
self._typSQuoteC = "'"
self._typRepDQuote = False
self._typRepSQuote = False
self._typRepDash = False
self._typRepDots = False
self._typPadBefore = ""
self._typPadAfter = ""
self._typConf = AutoReplaceConfig(
typPadChar=" ",
typSQuoteO="'",
typSQuoteC="'",
typDQuoteO='"',
typDQuoteC='"',
typRepSQuote=False,
typRepDQuote=False,
typRepDash=False,
typRepDots=False,
typPadBefore="",
typPadAfter="",
)
# Completer
self._completer = MetaCompleter(self)
@@ -310,21 +328,19 @@ class GuiDocEditor(QPlainTextEdit):
created, and when the user changes the main editor preferences.
"""
# Typography
if CONFIG.fmtPadThin:
self._typPadChar = nwUnicode.U_THNBSP
else:
self._typPadChar = nwUnicode.U_NBSP
self._typSQuoteO = CONFIG.fmtSQuoteOpen
self._typSQuoteC = CONFIG.fmtSQuoteClose
self._typDQuoteO = CONFIG.fmtDQuoteOpen
self._typDQuoteC = CONFIG.fmtDQuoteClose
self._typRepDQuote = CONFIG.doReplaceDQuote
self._typRepSQuote = CONFIG.doReplaceSQuote
self._typRepDash = CONFIG.doReplaceDash
self._typRepDots = CONFIG.doReplaceDots
self._typPadBefore = CONFIG.fmtPadBefore
self._typPadAfter = CONFIG.fmtPadAfter
self._typConf = AutoReplaceConfig(
typPadChar=nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP,
typSQuoteO=CONFIG.fmtSQuoteOpen,
typSQuoteC=CONFIG.fmtSQuoteClose,
typDQuoteO=CONFIG.fmtDQuoteOpen,
typDQuoteC=CONFIG.fmtDQuoteClose,
typRepSQuote=CONFIG.doReplaceSQuote,
typRepDQuote=CONFIG.doReplaceDQuote,
typRepDash=CONFIG.doReplaceDash,
typRepDots=CONFIG.doReplaceDots,
typPadBefore=CONFIG.fmtPadBefore,
typPadAfter=CONFIG.fmtPadAfter,
)
# Reload spell check and dictionaries
SHARED.updateSpellCheckLanguage()
@@ -737,6 +753,7 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Requesting action: %s", action.name)
tConf = self._typConf
self._allowAutoReplace(False)
if action == nwDocAction.UNDO:
self.undo()
@@ -755,9 +772,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.MD_STRIKE:
self._toggleFormat(2, "~")
elif action == nwDocAction.S_QUOTE:
self._wrapSelection(self._typSQuoteO, self._typSQuoteC)
self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.D_QUOTE:
self._wrapSelection(self._typDQuoteO, self._typDQuoteC)
self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.SEL_ALL:
self._makeSelection(QTextCursor.SelectionType.Document)
elif action == nwDocAction.SEL_PARA:
@@ -783,9 +800,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.BLOCK_HSC:
self._formatBlock(nwDocAction.BLOCK_HSC)
elif action == nwDocAction.REPL_SNG:
self._replaceQuotes("'", self._typSQuoteO, self._typSQuoteC)
self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.REPL_DBL:
self._replaceQuotes("\"", self._typDQuoteO, self._typDQuoteC)
self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.RM_BREAKS:
self._removeInParLineBreaks()
elif action == nwDocAction.ALIGN_L:
@@ -857,13 +874,13 @@ class GuiDocEditor(QPlainTextEdit):
text = insert
elif isinstance(insert, nwDocInsert):
if insert == nwDocInsert.QUOTE_LS:
text = self._typSQuoteO
text = self._typConf.typSQuoteO
elif insert == nwDocInsert.QUOTE_RS:
text = self._typSQuoteC
text = self._typConf.typSQuoteC
elif insert == nwDocInsert.QUOTE_LD:
text = self._typDQuoteO
text = self._typConf.typDQuoteO
elif insert == nwDocInsert.QUOTE_RD:
text = self._typDQuoteC
text = self._typConf.typDQuoteC
elif insert == nwDocInsert.SYNOPSIS:
text = "%Synopsis: "
block = True
@@ -1978,88 +1995,98 @@ class GuiDocEditor(QPlainTextEdit):
if tLen < 1 or tPos-1 > tLen:
return
tOne = text[tPos-1:tPos]
tTwo = text[tPos-2:tPos]
tThree = text[tPos-3:tPos]
t1 = text[tPos-1:tPos]
t2 = text[tPos-2:tPos]
t3 = text[tPos-3:tPos]
t4 = text[tPos-4:tPos]
if not tOne:
if not t1:
return
nDelete = 0
tInsert = tOne
delete = 0
insert = t1
tConf = self._typConf
if self._typRepDQuote and tTwo[:1].isspace() and tTwo.endswith('"'):
nDelete = 1
tInsert = self._typDQuoteO
if tConf.typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
delete = 1
insert = tConf.typDQuoteO
elif self._typRepDQuote and tOne == '"':
nDelete = 1
elif tConf.typRepDQuote and t1 == '"':
delete = 1
if tPos == 1:
tInsert = self._typDQuoteO
elif tPos == 2 and tTwo == '>"':
tInsert = self._typDQuoteO
elif tPos == 3 and tThree == '>>"':
tInsert = self._typDQuoteO
insert = tConf.typDQuoteO
elif tPos == 2 and t2 == '>"':
insert = tConf.typDQuoteO
elif tPos == 3 and t3 == '>>"':
insert = tConf.typDQuoteO
else:
tInsert = self._typDQuoteC
insert = tConf.typDQuoteC
elif self._typRepSQuote and tTwo[:1].isspace() and tTwo.endswith("'"):
nDelete = 1
tInsert = self._typSQuoteO
elif tConf.typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
delete = 1
insert = tConf.typSQuoteO
elif self._typRepSQuote and tOne == "'":
nDelete = 1
elif tConf.typRepSQuote and t1 == "'":
delete = 1
if tPos == 1:
tInsert = self._typSQuoteO
elif tPos == 2 and tTwo == ">'":
tInsert = self._typSQuoteO
elif tPos == 3 and tThree == ">>'":
tInsert = self._typSQuoteO
insert = tConf.typSQuoteO
elif tPos == 2 and t2 == ">'":
insert = tConf.typSQuoteO
elif tPos == 3 and t3 == ">>'":
insert = tConf.typSQuoteO
else:
tInsert = self._typSQuoteC
insert = tConf.typSQuoteC
elif self._typRepDash and tThree == "---":
nDelete = 3
tInsert = nwUnicode.U_EMDASH
elif tConf.typRepDash and t4 == "----":
delete = 4
insert = nwUnicode.U_HBAR
elif self._typRepDash and tTwo == "--":
nDelete = 2
tInsert = nwUnicode.U_ENDASH
elif tConf.typRepDash and t3 == "---":
delete = 3
insert = nwUnicode.U_EMDASH
elif self._typRepDash and tTwo == nwUnicode.U_ENDASH + "-":
nDelete = 2
tInsert = nwUnicode.U_EMDASH
elif tConf.typRepDash and t2 == "--":
delete = 2
insert = nwUnicode.U_ENDASH
elif self._typRepDots and tThree == "...":
nDelete = 3
tInsert = nwUnicode.U_HELLIP
elif tConf.typRepDash and t2 == nwUnicode.U_ENDASH + "-":
delete = 2
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
nDelete = 1
tInsert = nwUnicode.U_PSEP
delete = 1
insert = nwUnicode.U_PSEP
tCheck = tInsert
if self._typPadBefore and tCheck in self._typPadBefore:
if self._allowSpaceBeforeColon(text, tCheck):
nDelete = max(nDelete, 1)
chkPos = tPos - nDelete - 1
check = insert
if tConf.typPadBefore and check in tConf.typPadBefore:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
chkPos = tPos - delete - 1
if chkPos >= 0 and text[chkPos].isspace():
# Strip existing space before inserting a new (#1061)
nDelete += 1
tInsert = self._typPadChar + tInsert
delete += 1
insert = tConf.typPadChar + insert
if self._typPadAfter and tCheck in self._typPadAfter:
if self._allowSpaceBeforeColon(text, tCheck):
nDelete = max(nDelete, 1)
tInsert = tInsert + self._typPadChar
if tConf.typPadAfter and check in tConf.typPadAfter:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
insert = insert + tConf.typPadChar
if nDelete > 0:
cursor.movePosition(QtMoveLeft, QtKeepAnchor, nDelete)
cursor.insertText(tInsert)
if delete > 0:
cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
cursor.insertText(insert)
# Re-highlight, since the auto-replace sometimes interferes with it
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
# Re-highlight, since the auto-replace sometimes interferes with it
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return
+10 -28
View File
@@ -40,7 +40,7 @@ from novelwriter.common import checkInt
from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.core.index import processComment
from novelwriter.enum import nwComment
from novelwriter.text.patterns import REGEX_PATTERNS
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__)
@@ -59,8 +59,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = (
"_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr",
"_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogLine",
"_narratorBreak",
"_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogParser",
)
def __init__(self, document: QTextDocument) -> None:
@@ -79,8 +78,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
self._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
self._dialogLine = ""
self._narratorBreak = ""
self._dialogParser = DialogParser()
self.initHighlighter()
@@ -136,8 +134,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.clear()
self._cmnRules.clear()
self._dialogLine = CONFIG.dialogLine.strip()[:1]
self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
self._dialogParser.initParser()
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
@@ -158,16 +155,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
# Dialogue
if CONFIG.dialogStyle > 0:
rxRule = REGEX_PATTERNS.dialogStyle
hlRule = {
0: self._hStyles["dialog"],
}
self._txtRules.append((rxRule, hlRule))
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
rxRule = REGEX_PATTERNS.altDialogStyle
# Alt Dialogue
if rxRule := REGEX_PATTERNS.altDialogStyle:
hlRule = {
0: self._hStyles["altdialog"],
}
@@ -403,17 +392,10 @@ class GuiDocHighlighter(QSyntaxHighlighter):
else: # Text Paragraph
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules
if self._dialogLine and text.startswith(self._dialogLine):
if self._narratorBreak:
tPos = 0
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 self._dialogParser.enabled:
for pos, end in self._dialogParser(text):
length = end - pos
self.setFormat(pos, length, self._hStyles["dialog"])
if hRules:
for rX, hRule in hRules:
+1 -1
View File
@@ -214,7 +214,7 @@ class GuiDocViewer(QTextBrowser):
sPos = self.verticalScrollBar().value()
qDoc = ToQTextDocument(SHARED.project)
qDoc.setJustify(CONFIG.doJustify)
qDoc.setDialogueHighlight(True)
qDoc.setDialogHighlight(True)
qDoc.setFont(CONFIG.textFont)
qDoc.setTheme(self._docTheme)
qDoc.initDocument()
+93 -15
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import re
from novelwriter import CONFIG
from novelwriter.common import compact, uniqueCompact
from novelwriter.constants import nwRegEx
@@ -82,26 +83,103 @@ class RegExPatterns:
return self._rxSCValue
@property
def dialogStyle(self) -> re.Pattern:
def dialogStyle(self) -> re.Pattern | None:
"""Dialogue detection rule based on user settings."""
symO = ""
symC = ""
if CONFIG.dialogStyle in (1, 3):
symO += CONFIG.fmtSQuoteOpen
symC += CONFIG.fmtSQuoteClose
if CONFIG.dialogStyle in (2, 3):
symO += CONFIG.fmtDQuoteOpen
symC += CONFIG.fmtDQuoteClose
if CONFIG.dialogStyle > 0:
symO = ""
symC = ""
if CONFIG.dialogStyle in (1, 3):
symO += CONFIG.fmtSQuoteOpen.strip()[:1]
symC += CONFIG.fmtSQuoteClose.strip()[:1]
if CONFIG.dialogStyle in (2, 3):
symO += CONFIG.fmtDQuoteOpen.strip()[:1]
symC += CONFIG.fmtDQuoteClose.strip()[:1]
rxEnd = "|$" if CONFIG.allowOpenDial else ""
return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)
rxEnd = "|$" if CONFIG.allowOpenDial else ""
return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)
return None
@property
def altDialogStyle(self) -> re.Pattern:
def altDialogStyle(self) -> re.Pattern | None:
"""Dialogue alternative rule based on user settings."""
symO = re.escape(CONFIG.altDialogOpen)
symC = re.escape(CONFIG.altDialogClose)
return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE)
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
symO = re.escape(compact(CONFIG.altDialogOpen))
symC = re.escape(compact(CONFIG.altDialogClose))
return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE)
return None
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
%%~path: 000000000000d/000000000000f
%%~kind: NOVEL/DOCUMENT
%%~hash: 89b54ddaec2fddfd220e91dd438c84fb3aef9fc2
%%~date: 2024-10-30 00:07:21/2024-10-30 00:07:26
%%~hash: e4148ea77e78c90c334d5dc46c38a2b7904ac117
%%~date: 2024-11-01 21:15:57/2024-11-01 21:16:01
# Novel
## Chapter
@@ -26,7 +26,7 @@ This is a paragraph of nonsense text.
This is another paragraph
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.”
@@ -1,5 +1,5 @@
<?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">
<name>New Project</name>
<author>Jane Doe</author>
@@ -28,7 +28,7 @@
<entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</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">
<meta expanded="yes" />
<name status="s000000" import="i000004">Novel</name>
@@ -46,7 +46,7 @@
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<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>
</item>
<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()
html = ToHtml(project)
html.initDocument()
html._isNovel = 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"
)
# 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.tokenizeText()
html.doConvert()
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"
)
# Alt. Dialogue
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
html.setDialogueHighlight(True)
# Alt Dialog
html._text = "## Chapter\n\nThis text ::has alt dialogue:: in it.\n\n"
html.tokenizeText()
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"
)
# 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 = (
"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()
tokens = BareTokenizer(project)
tokens.setDialogueHighlight(True)
tokens.setDialogHighlight(True)
tokens._handle = TMH
tokens._isNovel = True
@@ -1337,7 +1337,10 @@ def testFmtToken_Dialogue(mockGUI):
# Dialogue line
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.tokenizeText()
assert tokens._blocks == [(
@@ -1352,16 +1355,19 @@ def testFmtToken_Dialogue(mockGUI):
# Dialogue line with narrator break
CONFIG.narratorBreak = "\u2013"
tokens.setDialogueHighlight(True)
tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?\n"
tokens = BareTokenizer(project)
tokens.setDialogHighlight(True)
tokens._handle = TMH
tokens._isNovel = True
tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said\u2013, see?\n"
tokens.tokenizeText()
assert tokens._blocks == [(
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"),
(34, TextFmt.COL_E, ""),
(43, TextFmt.COL_B, "dialog"),
(44, TextFmt.COL_B, "dialog"),
(49, TextFmt.COL_E, ""),
],
BlockFmt.NONE
+1 -1
View File
@@ -263,7 +263,7 @@ def testFmtToOdt_DialogueFormatting(mockGUI):
"""Test formatting of dialogue."""
project = NWProject()
odt = ToOdt(project, isFlat=True)
odt.setDialogueHighlight(True)
odt.setDialogHighlight(True)
odt.initDocument()
oStyle = ODTParagraphStyle("test")
+1 -1
View File
@@ -442,7 +442,7 @@ def testFmtToQTextDocument_TextCharFormats(mockGUI):
# Convert before init
doc._text = "Blabla"
doc.setDialogueHighlight(True)
doc.setDialogHighlight(True)
doc.doConvert()
doc.tokenizeText()
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 docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor._typPadChar == nwUnicode.U_NBSP
assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP
assert docEditor.docHeader.itemTitle.text() == (
"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 docEditor.verticalScrollBarPolicy() == 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"
# 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)
for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ":
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 ... ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
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_Backspace, 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, 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)
# Insert spaces before and after quotes
docEditor._typPadBefore = "\u201d"
docEditor._typPadAfter = "\u201c"
CONFIG.fmtPadBefore = "\u201d"
CONFIG.fmtPadAfter = "\u201c"
docEditor.initEditor()
for c in "Some \"double quoted text with spaces padded\".":
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)
docEditor._typPadBefore = ""
docEditor._typPadAfter = ""
CONFIG.fmtPadBefore = ""
CONFIG.fmtPadAfter = ""
docEditor.initEditor()
# Dialogue Line
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
docEditor._typPadBefore = ":"
CONFIG.fmtPadBefore = ":"
docEditor.initEditor()
for c in "@object: NoSpaceAdded":
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)
docEditor._typPadBefore = ""
CONFIG.fmtPadBefore = ""
docEditor.initEditor()
# Indent and Align
# ================
+116 -7
View File
@@ -26,7 +26,7 @@ import pytest
from novelwriter import CONFIG
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]]:
@@ -267,6 +267,10 @@ def testTextPatterns_ShortcodesValue():
@pytest.mark.core
def testTextPatterns_DialogueStyle():
"""Test the dialogue style pattern regexes."""
# Before set, the regex is None
CONFIG.dialogStyle = 0
assert REGEX_PATTERNS.dialogStyle is None
# Set the config
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
@@ -280,6 +284,7 @@ def testTextPatterns_DialogueStyle():
CONFIG.allowOpenDial = False
regEx = REGEX_PATTERNS.dialogStyle
assert regEx is not None
# Defined single quotes are recognised
assert allMatches(regEx, "one \u2018two\u2019 three") == [
@@ -305,6 +310,7 @@ def testTextPatterns_DialogueStyle():
CONFIG.allowOpenDial = True
regEx = REGEX_PATTERNS.dialogStyle
assert regEx is not None
# Defined single quotes are recognised also when open
assert allMatches(regEx, "one \u2018two three") == [
@@ -320,19 +326,19 @@ def testTextPatterns_DialogueStyle():
@pytest.mark.core
def testTextPatterns_DialogueSpecial():
"""Test the special dialogue style pattern regexes."""
# Set the config
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO
# Before set, the regex is None
CONFIG.altDialogOpen = ""
CONFIG.altDialogClose = ""
assert REGEX_PATTERNS.altDialogStyle is None
CONFIG.dialogStyle = 3
# Set the config
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
# Alternative Dialogue
# ====================
regEx = REGEX_PATTERNS.altDialogStyle
assert regEx is not None
# With no padding
assert allMatches(regEx, "one ::two:: three") == [
@@ -343,3 +349,106 @@ def testTextPatterns_DialogueSpecial():
assert allMatches(regEx, "one :: two :: three") == [
[(":: 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),
]