novelWriter 2.5b1__py3-none-any.whl → 2.5rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/METADATA +1 -1
  2. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/RECORD +61 -61
  3. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/WHEEL +1 -1
  4. novelwriter/__init__.py +3 -3
  5. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  6. novelwriter/assets/i18n/project_pt_BR.json +74 -74
  7. novelwriter/assets/manual.pdf +0 -0
  8. novelwriter/assets/sample.zip +0 -0
  9. novelwriter/assets/themes/cyberpunk_night.conf +1 -0
  10. novelwriter/assets/themes/default_dark.conf +1 -0
  11. novelwriter/assets/themes/default_light.conf +1 -0
  12. novelwriter/assets/themes/dracula.conf +1 -0
  13. novelwriter/assets/themes/solarized_dark.conf +1 -0
  14. novelwriter/assets/themes/solarized_light.conf +1 -0
  15. novelwriter/common.py +2 -3
  16. novelwriter/config.py +67 -15
  17. novelwriter/constants.py +8 -10
  18. novelwriter/core/buildsettings.py +5 -3
  19. novelwriter/core/coretools.py +3 -1
  20. novelwriter/core/docbuild.py +1 -0
  21. novelwriter/core/tohtml.py +69 -29
  22. novelwriter/core/tokenizer.py +83 -14
  23. novelwriter/core/toodt.py +48 -21
  24. novelwriter/core/toqdoc.py +25 -9
  25. novelwriter/dialogs/about.py +10 -15
  26. novelwriter/dialogs/docmerge.py +16 -16
  27. novelwriter/dialogs/docsplit.py +16 -16
  28. novelwriter/dialogs/editlabel.py +6 -8
  29. novelwriter/dialogs/preferences.py +94 -68
  30. novelwriter/dialogs/projectsettings.py +10 -10
  31. novelwriter/dialogs/quotes.py +9 -5
  32. novelwriter/dialogs/wordlist.py +6 -6
  33. novelwriter/enum.py +4 -5
  34. novelwriter/extensions/configlayout.py +23 -4
  35. novelwriter/extensions/modified.py +22 -3
  36. novelwriter/extensions/{circularprogress.py → progressbars.py} +26 -3
  37. novelwriter/extensions/statusled.py +28 -22
  38. novelwriter/gui/doceditor.py +20 -11
  39. novelwriter/gui/dochighlight.py +30 -39
  40. novelwriter/gui/docviewer.py +21 -14
  41. novelwriter/gui/mainmenu.py +11 -11
  42. novelwriter/gui/outline.py +3 -3
  43. novelwriter/gui/projtree.py +19 -28
  44. novelwriter/gui/search.py +10 -1
  45. novelwriter/gui/statusbar.py +25 -29
  46. novelwriter/gui/theme.py +3 -0
  47. novelwriter/guimain.py +91 -84
  48. novelwriter/shared.py +10 -8
  49. novelwriter/text/patterns.py +113 -0
  50. novelwriter/tools/dictionaries.py +2 -8
  51. novelwriter/tools/lipsum.py +8 -12
  52. novelwriter/tools/manusbuild.py +9 -9
  53. novelwriter/tools/manuscript.py +10 -5
  54. novelwriter/tools/manussettings.py +7 -3
  55. novelwriter/tools/noveldetails.py +10 -10
  56. novelwriter/tools/welcome.py +10 -10
  57. novelwriter/tools/writingstats.py +3 -3
  58. novelwriter/types.py +5 -2
  59. novelwriter/extensions/simpleprogress.py +0 -53
  60. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/LICENSE.md +0 -0
  61. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/entry_points.txt +0 -0
  62. {novelWriter-2.5b1.dist-info → novelWriter-2.5rc1.dist-info}/top_level.txt +0 -0
novelwriter/constants.py CHANGED
@@ -57,17 +57,14 @@ class nwConst:
57
57
  STATUS_MSG_TIMEOUT = 15000 # milliseconds
58
58
  MAX_SEARCH_RESULT = 1000
59
59
 
60
- # Dialogs
61
- DLG_FINISHED = 2
62
-
63
60
 
64
61
  class nwRegEx:
65
62
 
66
63
  FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
67
- FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
68
- FMT_ST = r"(?<![\w\\])([~]{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
69
- FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:i|b|s|u|m|sup|sub)\])"
70
- FMT_SV = r"(?<!\\)(\[(?i)(?:footnote):)(.+?)(?<!\\)(\])"
64
+ FMT_EB = r"(?<![\w\\])(\*{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
65
+ FMT_ST = r"(?<![\w\\])(~{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
66
+ FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:b|i|s|u|m|sup|sub)\])"
67
+ FMT_SV = r"(?i)(?<!\\)(\[(?:footnote):)(.+?)(?<!\\)(\])"
71
68
 
72
69
 
73
70
  class nwShortcode:
@@ -107,6 +104,7 @@ class nwFiles:
107
104
  # Config Files
108
105
  CONF_FILE = "novelwriter.conf"
109
106
  RECENT_FILE = "recentProjects.json"
107
+ RECENT_PATH = "recentPaths.json"
110
108
 
111
109
  # Project Root Files
112
110
  PROJ_FILE = "nwProject.nwx"
@@ -414,7 +412,7 @@ class nwUnicode:
414
412
  U_EMDASH = "\u2014" # Long dash
415
413
  U_HBAR = "\u2015" # Horizontal bar
416
414
  U_HELLIP = "\u2026" # Ellipsis
417
- U_MAPOSS = "\u02bc" # Modifier letter single apostrophe
415
+ U_MAPOS = "\u02bc" # Modifier letter single apostrophe
418
416
  U_PRIME = "\u2032" # Prime
419
417
  U_DPRIME = "\u2033" # Double prime
420
418
 
@@ -481,7 +479,7 @@ class nwUnicode:
481
479
  H_EMDASH = "&mdash;"
482
480
  H_HBAR = "&#8213;"
483
481
  H_HELLIP = "&hellip;"
484
- H_MAPOSS = "&#700;"
482
+ H_MAPOS = "&#700;"
485
483
  H_PRIME = "&prime;"
486
484
  H_DPRIME = "&#8243;"
487
485
 
@@ -546,7 +544,7 @@ class nwHtmlUnicode():
546
544
  nwUnicode.U_EMDASH: nwUnicode.H_EMDASH,
547
545
  nwUnicode.U_HBAR: nwUnicode.H_HBAR,
548
546
  nwUnicode.U_HELLIP: nwUnicode.H_HELLIP,
549
- nwUnicode.U_MAPOSS: nwUnicode.H_MAPOSS,
547
+ nwUnicode.U_MAPOS: nwUnicode.H_MAPOS,
550
548
  nwUnicode.U_PRIME: nwUnicode.H_PRIME,
551
549
  nwUnicode.U_DPRIME: nwUnicode.H_DPRIME,
552
550
 
@@ -82,6 +82,7 @@ SETTINGS_TEMPLATE = {
82
82
  "format.stripUnicode": (bool, False),
83
83
  "format.replaceTabs": (bool, False),
84
84
  "format.keepBreaks": (bool, True),
85
+ "format.showDialogue": (bool, False),
85
86
  "format.firstLineIndent": (bool, False),
86
87
  "format.firstIndentWidth": (float, 1.4),
87
88
  "format.indentFirstPar": (bool, False),
@@ -131,6 +132,7 @@ SETTINGS_LABELS = {
131
132
  "format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"),
132
133
  "format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"),
133
134
  "format.keepBreaks": QT_TRANSLATE_NOOP("Builds", "Preserve Hard Line Breaks"),
135
+ "format.showDialogue": QT_TRANSLATE_NOOP("Builds", "Apply Dialogue Highlighting"),
134
136
 
135
137
  "format.grpParIndent": QT_TRANSLATE_NOOP("Builds", "First Line Indent"),
136
138
  "format.firstLineIndent": QT_TRANSLATE_NOOP("Builds", "Enable Indent"),
@@ -217,7 +219,7 @@ class BuildSettings:
217
219
  return self._order
218
220
 
219
221
  @property
220
- def lastPath(self) -> Path:
222
+ def lastBuildPath(self) -> Path:
221
223
  """The last used build path."""
222
224
  if self._path.is_dir():
223
225
  return self._path
@@ -291,7 +293,7 @@ class BuildSettings:
291
293
  self._order = value
292
294
  return
293
295
 
294
- def setLastPath(self, path: Path | str | None) -> None:
296
+ def setLastBuildPath(self, path: Path | str | None) -> None:
295
297
  """Set the last used build path."""
296
298
  if isinstance(path, str):
297
299
  path = Path(path)
@@ -459,7 +461,7 @@ class BuildSettings:
459
461
  self.setName(data.get("name", ""))
460
462
  self.setBuildID(data.get("uuid", ""))
461
463
  self.setOrder(data.get("order", 0))
462
- self.setLastPath(data.get("path", None))
464
+ self.setLastBuildPath(data.get("path", None))
463
465
  self.setLastBuildName(data.get("build", ""))
464
466
 
465
467
  buildFmt = str(data.get("format", ""))
@@ -348,7 +348,9 @@ class DocSearch:
348
348
  rxMatch = rxItt.next()
349
349
  pos = rxMatch.capturedStart()
350
350
  num = rxMatch.capturedLength()
351
- context = text[pos:pos+100].partition("\n")[0]
351
+ lim = text[:pos].rfind("\n") + 1
352
+ cut = text[lim:pos].rfind(" ") + lim + 1
353
+ context = text[cut:cut+100].partition("\n")[0]
352
354
  if context:
353
355
  results.append((pos, num, context))
354
356
  count += 1
@@ -338,6 +338,7 @@ class NWBuildDocument:
338
338
  bldObj.setJustify(self._build.getBool("format.justifyText"))
339
339
  bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
340
340
  bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
341
+ bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
341
342
  bldObj.setFirstLineIndent(
342
343
  self._build.getBool("format.firstLineIndent"),
343
344
  self._build.getFloat("format.firstIndentWidth"),
@@ -29,7 +29,6 @@ import logging
29
29
  from pathlib import Path
30
30
  from time import time
31
31
 
32
- from novelwriter import CONFIG
33
32
  from novelwriter.common import formatTimeStamp
34
33
  from novelwriter.constants import nwHeadFmt, nwHtmlUnicode, nwKeyWords, nwLabels
35
34
  from novelwriter.core.project import NWProject
@@ -38,24 +37,35 @@ from novelwriter.types import FONT_STYLE, FONT_WEIGHTS
38
37
 
39
38
  logger = logging.getLogger(__name__)
40
39
 
41
- HTML5_TAGS = {
42
- Tokenizer.FMT_B_B: "<strong>",
43
- Tokenizer.FMT_B_E: "</strong>",
44
- Tokenizer.FMT_I_B: "<em>",
45
- Tokenizer.FMT_I_E: "</em>",
46
- Tokenizer.FMT_D_B: "<del>",
47
- Tokenizer.FMT_D_E: "</del>",
48
- Tokenizer.FMT_U_B: "<span style='text-decoration: underline;'>",
49
- Tokenizer.FMT_U_E: "</span>",
50
- Tokenizer.FMT_M_B: "<mark>",
51
- Tokenizer.FMT_M_E: "</mark>",
52
- Tokenizer.FMT_SUP_B: "<sup>",
53
- Tokenizer.FMT_SUP_E: "</sup>",
54
- Tokenizer.FMT_SUB_B: "<sub>",
55
- Tokenizer.FMT_SUB_E: "</sub>",
56
- Tokenizer.FMT_STRIP: "",
40
+ # Each opener tag, with the id of its corresponding closer and tag format
41
+ HTML_OPENER: dict[int, tuple[int, str]] = {
42
+ Tokenizer.FMT_B_B: (Tokenizer.FMT_B_E, "<strong>"),
43
+ Tokenizer.FMT_I_B: (Tokenizer.FMT_I_E, "<em>"),
44
+ Tokenizer.FMT_D_B: (Tokenizer.FMT_D_E, "<del>"),
45
+ Tokenizer.FMT_U_B: (Tokenizer.FMT_U_E, "<span style='text-decoration: underline;'>"),
46
+ Tokenizer.FMT_M_B: (Tokenizer.FMT_M_E, "<mark>"),
47
+ Tokenizer.FMT_SUP_B: (Tokenizer.FMT_SUP_E, "<sup>"),
48
+ Tokenizer.FMT_SUB_B: (Tokenizer.FMT_SUB_E, "<sub>"),
49
+ Tokenizer.FMT_DL_B: (Tokenizer.FMT_DL_E, "<span class='dialog'>"),
50
+ Tokenizer.FMT_ADL_B: (Tokenizer.FMT_ADL_E, "<span class='altdialog'>"),
57
51
  }
58
52
 
53
+ # Each closer tag, with the id of its corresponding opener and tag format
54
+ HTML_CLOSER: dict[int, tuple[int, str]] = {
55
+ Tokenizer.FMT_B_E: (Tokenizer.FMT_B_B, "</strong>"),
56
+ Tokenizer.FMT_I_E: (Tokenizer.FMT_I_B, "</em>"),
57
+ Tokenizer.FMT_D_E: (Tokenizer.FMT_D_B, "</del>"),
58
+ Tokenizer.FMT_U_E: (Tokenizer.FMT_U_B, "</span>"),
59
+ Tokenizer.FMT_M_E: (Tokenizer.FMT_M_B, "</mark>"),
60
+ Tokenizer.FMT_SUP_E: (Tokenizer.FMT_SUP_B, "</sup>"),
61
+ Tokenizer.FMT_SUB_E: (Tokenizer.FMT_SUB_B, "</sub>"),
62
+ Tokenizer.FMT_DL_E: (Tokenizer.FMT_DL_B, "</span>"),
63
+ Tokenizer.FMT_ADL_E: (Tokenizer.FMT_ADL_B, "</span>"),
64
+ }
65
+
66
+ # Empty HTML tag record
67
+ HTML_NONE = (0, "")
68
+
59
69
 
60
70
  class ToHtml(Tokenizer):
61
71
  """Core: HTML Document Writer
@@ -184,7 +194,6 @@ class ToHtml(Tokenizer):
184
194
 
185
195
  if tStyle & self.A_PBB:
186
196
  aStyle.append("page-break-before: always;")
187
-
188
197
  if tStyle & self.A_PBA:
189
198
  aStyle.append("page-break-after: always;")
190
199
 
@@ -194,11 +203,13 @@ class ToHtml(Tokenizer):
194
203
  aStyle.append("margin-top: 0;")
195
204
 
196
205
  if tStyle & self.A_IND_L:
197
- aStyle.append(f"margin-left: {CONFIG.tabWidth:d}px;")
206
+ aStyle.append(f"margin-left: {self._blockIndent:.2f}em;")
198
207
  if tStyle & self.A_IND_R:
199
- aStyle.append(f"margin-right: {CONFIG.tabWidth:d}px;")
208
+ aStyle.append(f"margin-right: {self._blockIndent:.2f}em;")
209
+ if tStyle & self.A_IND_T:
210
+ aStyle.append(f"text-indent: {self._firstWidth:.2f}em;")
200
211
 
201
- if len(aStyle) > 0:
212
+ if aStyle:
202
213
  stVals = " ".join(aStyle)
203
214
  hStyle = f" style='{stVals}'"
204
215
  else:
@@ -431,6 +442,8 @@ class ToHtml(Tokenizer):
431
442
  styles.append(".break {text-align: left;}")
432
443
  styles.append(".synopsis {font-style: italic;}")
433
444
  styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}")
445
+ styles.append(".dialog {color: rgb(66, 113, 174);}")
446
+ styles.append(".altdialog {color: rgb(129, 55, 9);}")
434
447
 
435
448
  return styles
436
449
 
@@ -441,19 +454,46 @@ class ToHtml(Tokenizer):
441
454
  def _formatText(self, text: str, tFmt: T_Formats) -> str:
442
455
  """Apply formatting tags to text."""
443
456
  temp = text
444
- for pos, fmt, data in reversed(tFmt):
445
- html = ""
446
- if fmt == self.FMT_FNOTE:
457
+
458
+ # Build a list of all html tags that need to be inserted in the text.
459
+ # This is done in the forward direction, and a tag is only opened if it
460
+ # isn't already open, and only closed if it has previously been opened.
461
+ tags: list[tuple[int, str]] = []
462
+ state = dict.fromkeys(HTML_OPENER, False)
463
+ for pos, fmt, data in tFmt:
464
+ if m := HTML_OPENER.get(fmt):
465
+ if not state.get(fmt, True):
466
+ tags.append((pos, m[1]))
467
+ state[fmt] = True
468
+ elif m := HTML_CLOSER.get(fmt):
469
+ if state.get(m[0], False):
470
+ tags.append((pos, m[1]))
471
+ state[m[0]] = False
472
+ elif fmt == self.FMT_FNOTE:
447
473
  if data in self._footnotes:
448
474
  index = len(self._usedNotes) + 1
449
475
  self._usedNotes[data] = index
450
- html = f"<sup><a href='#footnote_{index}'>{index}</a></sup>"
476
+ tags.append((pos, f"<sup><a href='#footnote_{index}'>{index}</a></sup>"))
451
477
  else:
452
- html = "<sup>ERR</sup>"
453
- else:
454
- html = HTML5_TAGS.get(fmt, "ERR")
455
- temp = f"{temp[:pos]}{html}{temp[pos:]}"
478
+ tags.append((pos, "<sup>ERR</sup>"))
479
+
480
+ # Check all format types and close any tag that is still open. This
481
+ # ensures that unclosed tags don't spill over to the next paragraph.
482
+ end = len(text)
483
+ for opener, active in state.items():
484
+ if active:
485
+ closer = HTML_OPENER.get(opener, HTML_NONE)[0]
486
+ tags.append((end, HTML_CLOSER.get(closer, HTML_NONE)[1]))
487
+
488
+ # Insert all tags at their correct position, starting from the back.
489
+ # The reverse order ensures that the positions are not shifted while we
490
+ # insert tags.
491
+ for pos, tag in reversed(tags):
492
+ temp = f"{temp[:pos]}{tag}{temp[pos:]}"
493
+
494
+ # Replace all line breaks with proper HTML break tags
456
495
  temp = temp.replace("\n", "<br>")
496
+
457
497
  return stripEscape(temp)
458
498
 
459
499
  def _formatSynopsis(self, text: str, synopsis: bool) -> str:
@@ -36,13 +36,13 @@ from time import time
36
36
  from PyQt5.QtCore import QCoreApplication, QRegularExpression
37
37
  from PyQt5.QtGui import QFont
38
38
 
39
+ from novelwriter import CONFIG
39
40
  from novelwriter.common import checkInt, formatTimeStamp, numberToRoman
40
- from novelwriter.constants import (
41
- nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst
42
- )
41
+ from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwShortcode, nwUnicode, trConst
43
42
  from novelwriter.core.index import processComment
44
43
  from novelwriter.core.project import NWProject
45
44
  from novelwriter.enum import nwComment, nwItemLayout
45
+ from novelwriter.text.patterns import REGEX_PATTERNS
46
46
 
47
47
  logger = logging.getLogger(__name__)
48
48
 
@@ -85,8 +85,12 @@ class Tokenizer(ABC):
85
85
  FMT_SUP_E = 12 # End superscript
86
86
  FMT_SUB_B = 13 # Begin subscript
87
87
  FMT_SUB_E = 14 # End subscript
88
- FMT_FNOTE = 15 # Footnote marker
89
- FMT_STRIP = 16 # Strip the format code
88
+ FMT_DL_B = 15 # Begin dialogue
89
+ FMT_DL_E = 16 # End dialogue
90
+ FMT_ADL_B = 17 # Begin alt dialogue
91
+ FMT_ADL_E = 18 # End alt dialogue
92
+ FMT_FNOTE = 19 # Footnote marker
93
+ FMT_STRIP = 20 # Strip the format code
90
94
 
91
95
  # Block Type
92
96
  T_EMPTY = 1 # Empty line (new paragraph)
@@ -115,6 +119,10 @@ class Tokenizer(ABC):
115
119
  A_Z_BTMMRG = 0x0080 # Zero bottom margin
116
120
  A_IND_L = 0x0100 # Left indentation
117
121
  A_IND_R = 0x0200 # Right indentation
122
+ A_IND_T = 0x0400 # Text indentation
123
+
124
+ # Masks
125
+ M_ALIGNED = A_LEFT | A_RIGHT | A_CENTRE | A_JUSTIFY
118
126
 
119
127
  # Lookups
120
128
  L_HEADINGS = [T_TITLE, T_HEAD1, T_HEAD2, T_HEAD3, T_HEAD4]
@@ -189,7 +197,8 @@ class Tokenizer(ABC):
189
197
 
190
198
  # Instance Variables
191
199
  self._hFormatter = HeadingFormatter(self._project)
192
- self._noSep = True # Flag to indicate that we don't want a scene separator
200
+ self._noSep = True # Flag to indicate that we don't want a scene separator
201
+ self._showDialog = False # Flag for dialogue highlighting
193
202
 
194
203
  # This File
195
204
  self._isNovel = False # Document is a novel document
@@ -204,12 +213,12 @@ class Tokenizer(ABC):
204
213
 
205
214
  # Format RegEx
206
215
  self._rxMarkdown = [
207
- (QRegularExpression(nwRegEx.FMT_EI), [0, self.FMT_I_B, 0, self.FMT_I_E]),
208
- (QRegularExpression(nwRegEx.FMT_EB), [0, self.FMT_B_B, 0, self.FMT_B_E]),
209
- (QRegularExpression(nwRegEx.FMT_ST), [0, self.FMT_D_B, 0, self.FMT_D_E]),
216
+ (REGEX_PATTERNS.markdownItalic, [0, self.FMT_I_B, 0, self.FMT_I_E]),
217
+ (REGEX_PATTERNS.markdownBold, [0, self.FMT_B_B, 0, self.FMT_B_E]),
218
+ (REGEX_PATTERNS.markdownStrike, [0, self.FMT_D_B, 0, self.FMT_D_E]),
210
219
  ]
211
- self._rxShortCodes = QRegularExpression(nwRegEx.FMT_SC)
212
- self._rxShortCodeVals = QRegularExpression(nwRegEx.FMT_SV)
220
+ self._rxShortCodes = REGEX_PATTERNS.shortcodePlain
221
+ self._rxShortCodeVals = REGEX_PATTERNS.shortcodeValue
213
222
 
214
223
  self._shortCodeFmt = {
215
224
  nwShortcode.ITALIC_O: self.FMT_I_B, nwShortcode.ITALIC_C: self.FMT_I_E,
@@ -224,6 +233,8 @@ class Tokenizer(ABC):
224
233
  nwShortcode.FOOTNOTE_B: self.FMT_FNOTE,
225
234
  }
226
235
 
236
+ self._rxDialogue: list[tuple[QRegularExpression, int, int]] = []
237
+
227
238
  return
228
239
 
229
240
  ##
@@ -345,6 +356,29 @@ class Tokenizer(ABC):
345
356
  self._doJustify = state
346
357
  return
347
358
 
359
+ def setDialogueHighlight(self, state: bool) -> None:
360
+ """Enable or disable dialogue highlighting."""
361
+ self._rxDialogue = []
362
+ self._showDialog = state
363
+ if state:
364
+ if CONFIG.dialogStyle > 0:
365
+ self._rxDialogue.append((
366
+ REGEX_PATTERNS.dialogStyle, self.FMT_DL_B, self.FMT_DL_E
367
+ ))
368
+ if CONFIG.dialogLine:
369
+ self._rxDialogue.append((
370
+ REGEX_PATTERNS.dialogLine, self.FMT_DL_B, self.FMT_DL_E
371
+ ))
372
+ if CONFIG.narratorBreak:
373
+ self._rxDialogue.append((
374
+ REGEX_PATTERNS.narratorBreak, self.FMT_DL_E, self.FMT_DL_B
375
+ ))
376
+ if CONFIG.altDialogOpen and CONFIG.altDialogClose:
377
+ self._rxDialogue.append((
378
+ REGEX_PATTERNS.altDialogStyle, self.FMT_ADL_B, self.FMT_ADL_E
379
+ ))
380
+ return
381
+
348
382
  def setTitleMargins(self, upper: float, lower: float) -> None:
349
383
  """Set the upper and lower title margin."""
350
384
  self._marginTitle = (float(upper), float(lower))
@@ -481,7 +515,7 @@ class Tokenizer(ABC):
481
515
  self._text = xRep.sub(lambda x: repDict[x.group(0)], self._text)
482
516
 
483
517
  # Process the character translation map
484
- trDict = {nwUnicode.U_MAPOSS: nwUnicode.U_RSQUO}
518
+ trDict = {nwUnicode.U_MAPOS: nwUnicode.U_RSQUO}
485
519
  self._text = self._text.translate(str.maketrans(trDict))
486
520
 
487
521
  return
@@ -839,6 +873,7 @@ class Tokenizer(ABC):
839
873
  pLines: list[T_Token] = []
840
874
 
841
875
  tCount = len(tokens)
876
+ pIndent = True
842
877
  for n, cToken in enumerate(tokens):
843
878
 
844
879
  if n > 0:
@@ -846,6 +881,12 @@ class Tokenizer(ABC):
846
881
  if n < tCount - 1:
847
882
  nToken = tokens[n+1] # Look ahead
848
883
 
884
+ if not self._indentFirst and cToken[0] in self.L_SKIP_INDENT:
885
+ # Unless the indentFirst flag is set, we set up the next
886
+ # paragraph to not be indented if we see a block of a
887
+ # specific type
888
+ pIndent = False
889
+
849
890
  if cToken[0] == self.T_EMPTY:
850
891
  # We don't need to keep the empty lines after this pass
851
892
  pass
@@ -864,11 +905,27 @@ class Tokenizer(ABC):
864
905
  elif cToken[0] == self.T_TEXT:
865
906
  # Combine lines from the same paragraph
866
907
  pLines.append(cToken)
908
+
867
909
  if nToken[0] != self.T_TEXT:
910
+ # Next token is not text, so we add the buffer to tokens
868
911
  nLines = len(pLines)
912
+ cStyle = pLines[0][4]
913
+ if self._firstIndent and pIndent and not cStyle & self.M_ALIGNED:
914
+ # If paragraph indentation is enabled, not temporarily
915
+ # turned off, and the block is not aligned, we add the
916
+ # text indentation flag
917
+ cStyle |= self.A_IND_T
918
+
869
919
  if nLines == 1:
870
- self._tokens.append(pLines[0])
920
+ # The paragraph contains a single line, so we just
921
+ # save that directly to the token list
922
+ self._tokens.append((
923
+ self.T_TEXT, pLines[0][1], pLines[0][2], pLines[0][3], cStyle
924
+ ))
871
925
  elif nLines > 1:
926
+ # The paragraph contains multiple lines, so we need to
927
+ # join them according to the line break policy, and
928
+ # recompute all the formatting markers
872
929
  tTxt = ""
873
930
  tFmt: T_Formats = []
874
931
  for aToken in pLines:
@@ -876,9 +933,12 @@ class Tokenizer(ABC):
876
933
  tTxt += f"{aToken[2]}{lineSep}"
877
934
  tFmt.extend((p+tLen, fmt, key) for p, fmt, key in aToken[3])
878
935
  self._tokens.append((
879
- self.T_TEXT, pLines[0][1], tTxt[:-1], tFmt, pLines[0][4]
936
+ self.T_TEXT, pLines[0][1], tTxt[:-1], tFmt, cStyle
880
937
  ))
938
+
939
+ # Reset buffer and make sure text indent is on for next pass
881
940
  pLines = []
941
+ pIndent = True
882
942
 
883
943
  else:
884
944
  self._tokens.append(cToken)
@@ -1076,6 +1136,15 @@ class Tokenizer(ABC):
1076
1136
  f"{tHandle}:{rxMatch.captured(2)}",
1077
1137
  ))
1078
1138
 
1139
+ # Match Dialogue
1140
+ if self._rxDialogue:
1141
+ for regEx, fmtB, fmtE in self._rxDialogue:
1142
+ rxItt = regEx.globalMatch(text, 0)
1143
+ while rxItt.hasNext():
1144
+ rxMatch = rxItt.next()
1145
+ temp.append((rxMatch.capturedStart(0), 0, fmtB, ""))
1146
+ temp.append((rxMatch.capturedEnd(0), 0, fmtE, ""))
1147
+
1079
1148
  # Post-process text and format
1080
1149
  result = text
1081
1150
  formats = []
novelwriter/core/toodt.py CHANGED
@@ -82,13 +82,15 @@ TAG_SPAN = _mkTag("text", "span")
82
82
  TAG_STNM = _mkTag("text", "style-name")
83
83
 
84
84
  # Formatting Codes
85
- X_BLD = 0x01 # Bold format
86
- X_ITA = 0x02 # Italic format
87
- X_DEL = 0x04 # Strikethrough format
88
- X_UND = 0x08 # Underline format
89
- X_MRK = 0x10 # Marked format
90
- X_SUP = 0x20 # Superscript
91
- X_SUB = 0x40 # Subscript
85
+ X_BLD = 0x001 # Bold format
86
+ X_ITA = 0x002 # Italic format
87
+ X_DEL = 0x004 # Strikethrough format
88
+ X_UND = 0x008 # Underline format
89
+ X_MRK = 0x010 # Marked format
90
+ X_SUP = 0x020 # Superscript
91
+ X_SUB = 0x040 # Subscript
92
+ X_DLG = 0x080 # Dialogue
93
+ X_DLA = 0x100 # Alt. Dialogue
92
94
 
93
95
  # Formatting Masks
94
96
  M_BLD = ~X_BLD
@@ -98,6 +100,8 @@ M_UND = ~X_UND
98
100
  M_MRK = ~X_MRK
99
101
  M_SUP = ~X_SUP
100
102
  M_SUB = ~X_SUB
103
+ M_DLG = ~X_DLG
104
+ M_DLA = ~X_DLA
101
105
 
102
106
  # ODT Styles
103
107
  S_TITLE = "Title"
@@ -216,13 +220,15 @@ class ToOdt(Tokenizer):
216
220
  self._mDocRight = "2.000cm"
217
221
 
218
222
  # Colour
219
- self._colHead12 = None
220
- self._opaHead12 = None
221
- self._colHead34 = None
222
- self._opaHead34 = None
223
- self._colMetaTx = None
224
- self._opaMetaTx = None
225
- self._markText = "#ffffa6"
223
+ self._colHead12 = None
224
+ self._opaHead12 = None
225
+ self._colHead34 = None
226
+ self._opaHead34 = None
227
+ self._colMetaTx = None
228
+ self._opaMetaTx = None
229
+ self._colDialogM = None
230
+ self._colDialogA = None
231
+ self._markText = "#ffffa6"
226
232
 
227
233
  return
228
234
 
@@ -324,6 +330,10 @@ class ToOdt(Tokenizer):
324
330
  self._colMetaTx = "#813709"
325
331
  self._opaMetaTx = "100%"
326
332
 
333
+ if self._showDialog:
334
+ self._colDialogM = "#2a6099"
335
+ self._colDialogA = "#813709"
336
+
327
337
  self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
328
338
  self._fBlockIndent = self._emToCm(self._blockIndent)
329
339
  self._fTextIndent = self._emToCm(self._firstWidth)
@@ -438,7 +448,6 @@ class ToOdt(Tokenizer):
438
448
  self._result = "" # Not used, but cleared just in case
439
449
 
440
450
  xText = self._xText
441
- pIndent = True
442
451
  for tType, _, tText, tFormat, tStyle in self._tokens:
443
452
 
444
453
  # Styles
@@ -455,7 +464,6 @@ class ToOdt(Tokenizer):
455
464
 
456
465
  if tStyle & self.A_PBB:
457
466
  oStyle.setBreakBefore("page")
458
-
459
467
  if tStyle & self.A_PBA:
460
468
  oStyle.setBreakAfter("page")
461
469
 
@@ -469,16 +477,14 @@ class ToOdt(Tokenizer):
469
477
  if tStyle & self.A_IND_R:
470
478
  oStyle.setMarginRight(self._fBlockIndent)
471
479
 
472
- if not self._indentFirst and tType in self.L_SKIP_INDENT:
473
- pIndent = False
474
-
475
480
  # Process Text Types
476
481
  if tType == self.T_TEXT:
477
- if self._firstIndent and pIndent and oStyle.isUnaligned():
482
+ # Text indentation is processed here because there is a
483
+ # dedicated pre-defined style for it
484
+ if tStyle & self.A_IND_T:
478
485
  self._addTextPar(xText, S_FIND, oStyle, tText, tFmt=tFormat)
479
486
  else:
480
487
  self._addTextPar(xText, S_TEXT, oStyle, tText, tFmt=tFormat)
481
- pIndent = True
482
488
 
483
489
  elif tType == self.T_TITLE:
484
490
  # Title must be text:p
@@ -688,6 +694,14 @@ class ToOdt(Tokenizer):
688
694
  xFmt |= X_SUB
689
695
  elif fFmt == self.FMT_SUB_E:
690
696
  xFmt &= M_SUB
697
+ elif fFmt == self.FMT_DL_B:
698
+ xFmt |= X_DLG
699
+ elif fFmt == self.FMT_DL_E:
700
+ xFmt &= M_DLG
701
+ elif fFmt == self.FMT_ADL_B:
702
+ xFmt |= X_DLA
703
+ elif fFmt == self.FMT_ADL_E:
704
+ xFmt &= M_DLA
691
705
  elif fFmt == self.FMT_FNOTE:
692
706
  xNode = self._generateFootnote(fData)
693
707
  elif fFmt == self.FMT_STRIP:
@@ -761,6 +775,10 @@ class ToOdt(Tokenizer):
761
775
  style.setTextPosition("super")
762
776
  if hFmt & X_SUB:
763
777
  style.setTextPosition("sub")
778
+ if hFmt & X_DLG:
779
+ style.setColour(self._colDialogM)
780
+ if hFmt & X_DLA:
781
+ style.setColour(self._colDialogA)
764
782
  self._autoText[hFmt] = style
765
783
 
766
784
  return style.name
@@ -1361,6 +1379,7 @@ class ODTTextStyle:
1361
1379
  self._tAttr = {
1362
1380
  "font-weight": ["fo", None],
1363
1381
  "font-style": ["fo", None],
1382
+ "color": ["fo", None],
1364
1383
  "background-color": ["fo", None],
1365
1384
  "text-position": ["style", None],
1366
1385
  "text-line-through-style": ["style", None],
@@ -1395,6 +1414,14 @@ class ODTTextStyle:
1395
1414
  self._tAttr["font-style"][1] = None
1396
1415
  return
1397
1416
 
1417
+ def setColour(self, value: str | None) -> None:
1418
+ """Set text colour."""
1419
+ if value and len(value) == 7 and value[0] == "#":
1420
+ self._tAttr["color"][1] = value
1421
+ else:
1422
+ self._tAttr["color"][1] = None
1423
+ return
1424
+
1398
1425
  def setBackgroundColour(self, value: str | None) -> None:
1399
1426
  """Set text background colour."""
1400
1427
  if value and len(value) == 7 and value[0] == "#":