novelWriter 2.3.1__py3-none-any.whl → 2.4__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 (119) hide show
  1. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/METADATA +5 -6
  2. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/RECORD +114 -107
  3. novelwriter/__init__.py +17 -10
  4. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  5. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  6. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  7. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  8. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  9. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  10. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  11. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  12. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  13. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  14. novelwriter/assets/icons/none.svg +4 -0
  15. novelwriter/assets/icons/typicons_dark/icons.conf +4 -0
  16. novelwriter/assets/icons/typicons_dark/nw_tb-mark.svg +7 -0
  17. novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg +1 -1
  18. novelwriter/assets/icons/typicons_dark/typ_refresh.svg +1 -1
  19. novelwriter/assets/icons/typicons_dark/typ_search-grey.svg +4 -0
  20. novelwriter/assets/icons/typicons_dark/typ_times.svg +1 -1
  21. novelwriter/assets/icons/typicons_dark/typ_unfold-hidden.svg +4 -0
  22. novelwriter/assets/icons/typicons_dark/typ_unfold-visible.svg +4 -0
  23. novelwriter/assets/icons/typicons_light/icons.conf +4 -0
  24. novelwriter/assets/icons/typicons_light/nw_tb-mark.svg +7 -0
  25. novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg +1 -1
  26. novelwriter/assets/icons/typicons_light/typ_refresh.svg +1 -1
  27. novelwriter/assets/icons/typicons_light/typ_search-grey.svg +4 -0
  28. novelwriter/assets/icons/typicons_light/typ_times.svg +1 -1
  29. novelwriter/assets/icons/typicons_light/typ_unfold-hidden.svg +4 -0
  30. novelwriter/assets/icons/typicons_light/typ_unfold-visible.svg +4 -0
  31. novelwriter/assets/manual.pdf +0 -0
  32. novelwriter/assets/sample.zip +0 -0
  33. novelwriter/assets/syntax/default_dark.conf +1 -0
  34. novelwriter/assets/syntax/default_light.conf +1 -0
  35. novelwriter/assets/syntax/grey_dark.conf +1 -0
  36. novelwriter/assets/syntax/grey_light.conf +1 -0
  37. novelwriter/assets/syntax/light_owl.conf +1 -0
  38. novelwriter/assets/syntax/night_owl.conf +1 -0
  39. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  40. novelwriter/assets/syntax/solarized_light.conf +1 -0
  41. novelwriter/assets/syntax/tomorrow.conf +1 -0
  42. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  43. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  44. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  45. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  46. novelwriter/assets/text/credits_en.htm +25 -23
  47. novelwriter/common.py +12 -4
  48. novelwriter/config.py +47 -16
  49. novelwriter/constants.py +5 -6
  50. novelwriter/core/buildsettings.py +64 -44
  51. novelwriter/core/coretools.py +97 -13
  52. novelwriter/core/docbuild.py +74 -7
  53. novelwriter/core/document.py +24 -3
  54. novelwriter/core/index.py +31 -112
  55. novelwriter/core/project.py +10 -15
  56. novelwriter/core/projectxml.py +1 -1
  57. novelwriter/core/sessions.py +2 -2
  58. novelwriter/core/spellcheck.py +3 -3
  59. novelwriter/core/status.py +6 -5
  60. novelwriter/core/storage.py +8 -2
  61. novelwriter/core/tohtml.py +22 -25
  62. novelwriter/core/tokenizer.py +417 -233
  63. novelwriter/core/tomd.py +17 -8
  64. novelwriter/core/toodt.py +386 -351
  65. novelwriter/core/tree.py +8 -8
  66. novelwriter/dialogs/about.py +9 -11
  67. novelwriter/dialogs/docmerge.py +17 -14
  68. novelwriter/dialogs/docsplit.py +20 -19
  69. novelwriter/dialogs/editlabel.py +5 -4
  70. novelwriter/dialogs/preferences.py +32 -40
  71. novelwriter/dialogs/projectsettings.py +31 -28
  72. novelwriter/dialogs/quotes.py +10 -9
  73. novelwriter/dialogs/wordlist.py +17 -14
  74. novelwriter/enum.py +17 -14
  75. novelwriter/error.py +14 -12
  76. novelwriter/extensions/circularprogress.py +12 -8
  77. novelwriter/extensions/configlayout.py +1 -3
  78. novelwriter/extensions/modified.py +51 -2
  79. novelwriter/extensions/pagedsidebar.py +16 -14
  80. novelwriter/extensions/simpleprogress.py +3 -1
  81. novelwriter/extensions/statusled.py +3 -1
  82. novelwriter/extensions/switch.py +10 -9
  83. novelwriter/extensions/switchbox.py +14 -13
  84. novelwriter/extensions/versioninfo.py +1 -1
  85. novelwriter/gui/doceditor.py +433 -496
  86. novelwriter/gui/dochighlight.py +54 -33
  87. novelwriter/gui/docviewer.py +162 -175
  88. novelwriter/gui/docviewerpanel.py +20 -37
  89. novelwriter/gui/editordocument.py +15 -4
  90. novelwriter/gui/itemdetails.py +51 -54
  91. novelwriter/gui/mainmenu.py +37 -16
  92. novelwriter/gui/noveltree.py +31 -37
  93. novelwriter/gui/outline.py +120 -98
  94. novelwriter/gui/projtree.py +61 -67
  95. novelwriter/gui/search.py +362 -0
  96. novelwriter/gui/sidebar.py +36 -45
  97. novelwriter/gui/statusbar.py +14 -14
  98. novelwriter/gui/theme.py +107 -32
  99. novelwriter/guimain.py +209 -202
  100. novelwriter/shared.py +31 -6
  101. novelwriter/text/counting.py +138 -0
  102. novelwriter/tools/dictionaries.py +15 -14
  103. novelwriter/tools/lipsum.py +20 -17
  104. novelwriter/tools/manusbuild.py +43 -35
  105. novelwriter/tools/manuscript.py +381 -104
  106. novelwriter/tools/manussettings.py +262 -125
  107. novelwriter/tools/noveldetails.py +20 -18
  108. novelwriter/tools/welcome.py +52 -49
  109. novelwriter/tools/writingstats.py +61 -55
  110. novelwriter/types.py +90 -0
  111. novelwriter/core/__init__.py +0 -3
  112. novelwriter/dialogs/__init__.py +0 -3
  113. novelwriter/extensions/__init__.py +0 -3
  114. novelwriter/gui/__init__.py +0 -3
  115. novelwriter/tools/__init__.py +0 -3
  116. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/LICENSE.md +0 -0
  117. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/WHEEL +0 -0
  118. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/entry_points.txt +0 -0
  119. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/top_level.txt +0 -0
novelwriter/core/index.py CHANGED
@@ -3,7 +3,6 @@ novelWriter – Project Index
3
3
  ===========================
4
4
 
5
5
  File History:
6
- Created: 2019-04-22 [0.0.1] countWords
7
6
  Created: 2019-05-27 [0.1.4] NWIndex
8
7
  Created: 2022-05-28 [2.0rc1] IndexItem
9
8
  Created: 2022-05-28 [2.0rc1] IndexHeading
@@ -34,13 +33,14 @@ import logging
34
33
  from time import time
35
34
  from typing import TYPE_CHECKING
36
35
  from pathlib import Path
37
- from collections.abc import ItemsView, Iterable, Iterator
36
+ from collections.abc import ItemsView, Iterable
38
37
 
39
38
  from novelwriter import SHARED
40
39
  from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
41
40
  from novelwriter.error import logException
42
41
  from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode
43
- from novelwriter.constants import nwFiles, nwKeyWords, nwRegEx, nwUnicode, nwHeaders
42
+ from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders
43
+ from novelwriter.text.counting import standardCounter
44
44
 
45
45
  if TYPE_CHECKING: # pragma: no cover
46
46
  from novelwriter.core.item import NWItem
@@ -122,9 +122,8 @@ class NWIndex:
122
122
  self.clearIndex()
123
123
  for nwItem in self._project.tree:
124
124
  if nwItem.isFileType():
125
- tHandle = nwItem.itemHandle
126
- doc = self._project.storage.getDocument(tHandle)
127
- self.scanText(tHandle, doc.readDocument() or "", blockSignal=True)
125
+ text = self._project.storage.getDocumentText(nwItem.itemHandle)
126
+ self.scanText(nwItem.itemHandle, text, blockSignal=True)
128
127
  self._indexBroken = False
129
128
  SHARED.indexSignalProxy({"event": "buildIndex"})
130
129
  return
@@ -142,17 +141,15 @@ class NWIndex:
142
141
  })
143
142
  return
144
143
 
145
- def reIndexHandle(self, tHandle: str | None) -> bool:
144
+ def reIndexHandle(self, tHandle: str | None) -> None:
146
145
  """Put a file back into the index. This is used when files are
147
146
  moved from the archive or trash folders back into the active
148
147
  project.
149
148
  """
150
149
  if tHandle and self._project.tree.checkType(tHandle, nwItemType.FILE):
151
150
  logger.debug("Re-indexing item '%s'", tHandle)
152
- doc = self._project.storage.getDocument(tHandle)
153
- self.scanText(tHandle, doc.readDocument() or "")
154
- return True
155
- return False
151
+ self.scanText(tHandle, self._project.storage.getDocumentText(tHandle))
152
+ return
156
153
 
157
154
  def indexChangedSince(self, checkTime: int | float) -> bool:
158
155
  """Check if the index has changed since a given time."""
@@ -266,7 +263,7 @@ class NWIndex:
266
263
  self._itemIndex.add(tHandle, tItem)
267
264
 
268
265
  # Run word counter for the whole text
269
- cC, wC, pC = countWords(text)
266
+ cC, wC, pC = standardCounter(text)
270
267
  tItem.setCharCount(cC)
271
268
  tItem.setWordCount(wC)
272
269
  tItem.setParaCount(pC)
@@ -307,7 +304,7 @@ class NWIndex:
307
304
  nTitle = 0 # Line Number of the previous title
308
305
  cTitle = TT_NONE # Tag of the current title
309
306
  pTitle = TT_NONE # Tag of the previous title
310
- canSetHeader = True # First header has not yet been set
307
+ canSetHead = True # First heading has not yet been set
311
308
 
312
309
  lines = text.splitlines()
313
310
  for n, line in enumerate(lines, start=1):
@@ -320,9 +317,9 @@ class NWIndex:
320
317
  if hDepth == "H0":
321
318
  continue
322
319
 
323
- if canSetHeader:
320
+ if canSetHead:
324
321
  nwItem.setMainHeading(hDepth)
325
- canSetHeader = False
322
+ canSetHead = False
326
323
 
327
324
  cTitle = self._itemIndex.addItemHeading(tHandle, n, hDepth, hText)
328
325
  if cTitle != TT_NONE:
@@ -383,7 +380,7 @@ class NWIndex:
383
380
  return
384
381
 
385
382
  def _splitHeading(self, line: str) -> tuple[str, str]:
386
- """Split a heading into its header level and text value."""
383
+ """Split a heading into its heading level and text value."""
387
384
  if line.startswith("# "):
388
385
  return "H1", line[2:].strip()
389
386
  elif line.startswith("## "):
@@ -396,11 +393,13 @@ class NWIndex:
396
393
  return "H1", line[3:].strip()
397
394
  elif line.startswith("##! "):
398
395
  return "H2", line[4:].strip()
396
+ elif line.startswith("###! "):
397
+ return "H3", line[5:].strip()
399
398
  return "H0", ""
400
399
 
401
400
  def _indexWordCounts(self, tHandle: str, text: str, sTitle: str) -> None:
402
401
  """Count text stats and save the counts to the index."""
403
- cC, wC, pC = countWords(text)
402
+ cC, wC, pC = standardCounter(text)
404
403
  self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC)
405
404
  return
406
405
 
@@ -515,16 +514,21 @@ class NWIndex:
515
514
  """Get the index data for a given item."""
516
515
  return self._itemIndex[tHandle]
517
516
 
518
- def getItemHeader(self, tHandle: str, sTitle: str) -> IndexHeading | None:
519
- """Get the header entry for a specific item and heading."""
520
- tItem = self._itemIndex[tHandle]
521
- if isinstance(tItem, IndexItem):
517
+ def getItemHeading(self, tHandle: str, sTitle: str) -> IndexHeading | None:
518
+ """Get the heading entry for a specific item and heading."""
519
+ if tItem := self._itemIndex[tHandle]:
522
520
  return tItem[sTitle]
523
521
  return None
524
522
 
523
+ def iterItemHeadings(self, tHandle: str) -> Iterable[tuple[str, IndexHeading]]:
524
+ """Get all headings for a specific item."""
525
+ if tItem := self._itemIndex[tHandle]:
526
+ yield from tItem.items()
527
+ return []
528
+
525
529
  def novelStructure(
526
530
  self, rootHandle: str | None = None, activeOnly: bool = True
527
- ) -> Iterator[tuple[str, str, str, IndexHeading]]:
531
+ ) -> Iterable[tuple[str, str, str, IndexHeading]]:
528
532
  """Iterate over all titles in the novel, in the correct order as
529
533
  they appear in the tree view and in the respective document
530
534
  files, but skipping all note files.
@@ -666,7 +670,7 @@ class NWIndex:
666
670
 
667
671
  def getTagsData(
668
672
  self, activeOnly: bool = True
669
- ) -> Iterator[tuple[str, str, str, IndexItem | None, IndexHeading | None]]:
673
+ ) -> Iterable[tuple[str, str, str, IndexItem | None, IndexHeading | None]]:
670
674
  """Return all known tags."""
671
675
  for tag, data in self._tagsIndex.items():
672
676
  iItem = self._itemIndex[data.get("handle")]
@@ -825,7 +829,7 @@ class ItemIndex:
825
829
  class around a single storage dictionary with a set of utility
826
830
  functions for setting and accessing the index data. Each indexed
827
831
  item is stored in an IndexItem object, which again holds an
828
- IndexHeading object for each header of the text.
832
+ IndexHeading object for each heading of the text.
829
833
  """
830
834
 
831
835
  __slots__ = ("_project", "_items")
@@ -898,13 +902,11 @@ class ItemIndex:
898
902
 
899
903
  if rHandle is None:
900
904
  for sTitle in self._items[tHandle].headings():
901
- hItem = self._items[tHandle][sTitle]
902
- if hItem:
905
+ if hItem := self._items[tHandle][sTitle]:
903
906
  yield tHandle, sTitle, hItem
904
907
  elif tItem.itemRoot == rHandle:
905
908
  for sTitle in self._items[tHandle].headings():
906
- hItem = self._items[tHandle][sTitle]
907
- if hItem:
909
+ if hItem := self._items[tHandle][sTitle]:
908
910
  yield tHandle, sTitle, hItem
909
911
 
910
912
  return
@@ -1200,7 +1202,7 @@ class IndexHeading:
1200
1202
  ##
1201
1203
 
1202
1204
  def setLevel(self, level: str) -> None:
1203
- """Set the level of the header if it's a valid value."""
1205
+ """Set the level of the heading if it's a valid value."""
1204
1206
  if level in nwHeaders.H_VALID:
1205
1207
  self._level = level
1206
1208
  return
@@ -1315,86 +1317,3 @@ def processComment(text: str) -> tuple[nwComment, str, int]:
1315
1317
  if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
1316
1318
  return CLASSIFIERS[clean], content.strip(), text.find(":") + 1
1317
1319
  return nwComment.PLAIN, check, 0
1318
-
1319
-
1320
- def countWords(text: str) -> tuple[int, int, int]:
1321
- """Count words in a piece of text, skipping special syntax and
1322
- comments.
1323
- """
1324
- charCount = 0
1325
- wordCount = 0
1326
- paraCount = 0
1327
- prevEmpty = True
1328
-
1329
- if not isinstance(text, str):
1330
- return charCount, wordCount, paraCount
1331
-
1332
- # We need to treat dashes as word separators for counting words.
1333
- # The check+replace approach is much faster than direct replace for
1334
- # large texts, and a bit slower for small texts, but in the latter
1335
- # case it doesn't really matter.
1336
- if nwUnicode.U_ENDASH in text:
1337
- text = text.replace(nwUnicode.U_ENDASH, " ")
1338
- if nwUnicode.U_EMDASH in text:
1339
- text = text.replace(nwUnicode.U_EMDASH, " ")
1340
-
1341
- # Strip shortcodes
1342
- if "[" in text:
1343
- text = nwRegEx.RX_SC.sub("", text)
1344
-
1345
- for line in text.splitlines():
1346
-
1347
- countPara = True
1348
-
1349
- if not line:
1350
- prevEmpty = True
1351
- continue
1352
-
1353
- if line[0] == "@" or line[0] == "%":
1354
- continue
1355
-
1356
- if line[0] == "[":
1357
- check = line.lower()
1358
- if check.startswith(("[newpage]", "[new page]", "[vspace]")):
1359
- continue
1360
- elif check.startswith("[vspace:") and line.endswith("]"):
1361
- continue
1362
-
1363
- elif line[0] == "#":
1364
- if line[:5] == "#### ":
1365
- line = line[5:]
1366
- countPara = False
1367
- elif line[:4] == "### ":
1368
- line = line[4:]
1369
- countPara = False
1370
- elif line[:3] == "## ":
1371
- line = line[3:]
1372
- countPara = False
1373
- elif line[:2] == "# ":
1374
- line = line[2:]
1375
- countPara = False
1376
- elif line[:3] == "#! ":
1377
- line = line[3:]
1378
- countPara = False
1379
- elif line[:4] == "##! ":
1380
- line = line[4:]
1381
- countPara = False
1382
-
1383
- elif line[0] == ">" or line[-1] == "<":
1384
- if line[:2] == ">>":
1385
- line = line[2:].lstrip(" ")
1386
- elif line[:1] == ">":
1387
- line = line[1:].lstrip(" ")
1388
- if line[-2:] == "<<":
1389
- line = line[:-2].rstrip(" ")
1390
- elif line[-1:] == "<":
1391
- line = line[:-1].rstrip(" ")
1392
-
1393
- wordCount += len(line.split())
1394
- charCount += len(line)
1395
- if countPara and prevEmpty:
1396
- paraCount += 1
1397
-
1398
- prevEmpty = not countPara
1399
-
1400
- return charCount, wordCount, paraCount
@@ -31,7 +31,7 @@ from time import time
31
31
  from typing import TYPE_CHECKING
32
32
  from pathlib import Path
33
33
  from functools import partial
34
- from collections.abc import Iterator
34
+ from collections.abc import Iterable
35
35
 
36
36
  from PyQt5.QtCore import QCoreApplication
37
37
 
@@ -175,12 +175,10 @@ class NWProject:
175
175
  """Write content to a new document after it is created. This
176
176
  will not run if the file exists and is not empty.
177
177
  """
178
- tItem = self._tree[tHandle]
179
- if not (tItem and tItem.isFileType()):
178
+ if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
180
179
  return False
181
180
 
182
- newDoc = self._storage.getDocument(tHandle)
183
- if (newDoc.readDocument() or "").strip():
181
+ if self._storage.getDocumentText(tHandle).strip():
184
182
  return False
185
183
 
186
184
  indent = "#"*minmax(hLevel, 1, 4)
@@ -191,7 +189,7 @@ class NWProject:
191
189
  else:
192
190
  tItem.setLayout(nwItemLayout.NOTE)
193
191
 
194
- newDoc.writeDocument(text)
192
+ self._storage.getDocument(tHandle).writeDocument(text)
195
193
  self._index.scanText(tHandle, text)
196
194
 
197
195
  return True
@@ -200,21 +198,18 @@ class NWProject:
200
198
  """Copy content to a new document after it is created. This
201
199
  will not run if the file exists and is not empty.
202
200
  """
203
- tItem = self._tree[tHandle]
204
- if not (tItem and tItem.isFileType()):
201
+ if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
205
202
  return False
206
203
 
207
- sItem = self._tree[sHandle]
208
- if not (sItem and sItem.isFileType()):
204
+ if not ((sItem := self._tree[sHandle]) and sItem.isFileType()):
209
205
  return False
210
206
 
211
- newDoc = self._storage.getDocument(tHandle)
212
- if (newDoc.readDocument() or "").strip():
207
+ if self._storage.getDocumentText(tHandle).strip():
213
208
  return False
214
209
 
215
210
  logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle)
216
- text = self._storage.getDocument(sHandle).readDocument() or ""
217
- newDoc.writeDocument(text)
211
+ text = self._storage.getDocumentText(sHandle)
212
+ self._storage.getDocument(tHandle).writeDocument(text)
218
213
  sItem.setLayout(tItem.itemLayout)
219
214
  self._index.scanText(tHandle, text)
220
215
 
@@ -517,7 +512,7 @@ class NWProject:
517
512
  # Class Methods
518
513
  ##
519
514
 
520
- def iterProjectItems(self) -> Iterator[NWItem]:
515
+ def iterProjectItems(self) -> Iterable[NWItem]:
521
516
  """This function ensures that the item tree loaded is sent to
522
517
  the GUI tree view in such a way that the tree can be built. That
523
518
  is, the parent item must be sent before its child. In principle,
@@ -449,7 +449,7 @@ class ProjectXMLReader:
449
449
  result[xEntry.attrib["key"]] = checkString(xEntry.text, "")
450
450
  return result
451
451
 
452
- def _parseDictTagText(self, xItem) -> dict:
452
+ def _parseDictTagText(self, xItem: ET.Element) -> dict:
453
453
  """Parse a dictionary stored with key as the tag and the value
454
454
  as the text property.
455
455
  """
@@ -29,7 +29,7 @@ import logging
29
29
  from time import time
30
30
  from typing import TYPE_CHECKING
31
31
  from pathlib import Path
32
- from collections.abc import Iterator
32
+ from collections.abc import Iterable
33
33
 
34
34
  from novelwriter.error import logException
35
35
  from novelwriter.common import formatTimeStamp
@@ -110,7 +110,7 @@ class NWSessionLog:
110
110
 
111
111
  return True
112
112
 
113
- def iterRecords(self) -> Iterator[dict]:
113
+ def iterRecords(self) -> Iterable[dict]:
114
114
  """Iterate through all records in the log."""
115
115
  sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
116
116
  if isinstance(sessFile, Path) and sessFile.is_file():
@@ -74,7 +74,7 @@ class NWSpellEnchant:
74
74
  # Setters
75
75
  ##
76
76
 
77
- def setLanguage(self, language: str | None):
77
+ def setLanguage(self, language: str | None) -> None:
78
78
  """Load a dictionary for the language specified in the config.
79
79
  If that fails, we load a mock dictionary so that lookups don't
80
80
  crash. Note that enchant will allow loading an empty string as
@@ -182,10 +182,10 @@ class FakeEnchant:
182
182
  def check(self, word: str) -> bool:
183
183
  return True
184
184
 
185
- def suggest(self, word) -> list[str]:
185
+ def suggest(self, word: str) -> list[str]:
186
186
  return []
187
187
 
188
- def add_to_session(self, word: str):
188
+ def add_to_session(self, word: str) -> None:
189
189
  return
190
190
 
191
191
  # END Class FakeEnchant
@@ -28,13 +28,14 @@ import random
28
28
  import logging
29
29
 
30
30
  from typing import TYPE_CHECKING, Literal
31
- from collections.abc import ItemsView, Iterator, KeysView, ValuesView
31
+ from collections.abc import ItemsView, Iterable, Iterator, KeysView, ValuesView
32
32
 
33
33
  from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor
34
- from PyQt5.QtCore import QRectF, Qt
34
+ from PyQt5.QtCore import QRectF
35
35
 
36
36
  from novelwriter import CONFIG
37
37
  from novelwriter.common import minmax, simplified
38
+ from novelwriter.types import QtPaintAnitAlias, QtTransparent
38
39
 
39
40
  if TYPE_CHECKING: # pragma: no cover
40
41
  from typing import TypeGuard # Requires Python 3.10
@@ -193,7 +194,7 @@ class NWStatus:
193
194
  self._store[key]["count"] += 1
194
195
  return
195
196
 
196
- def pack(self) -> Iterator[tuple[str, dict]]:
197
+ def pack(self) -> Iterable[tuple[str, dict]]:
197
198
  """Pack the status entries into a dictionary."""
198
199
  for key, data in self._store.items():
199
200
  yield (data["name"], {
@@ -248,10 +249,10 @@ class NWStatus:
248
249
  def _createIcon(self, red: int, green: int, blue: int) -> QIcon:
249
250
  """Generate an icon for a status label."""
250
251
  pixmap = QPixmap(self._iPX, self._iPX)
251
- pixmap.fill(Qt.transparent)
252
+ pixmap.fill(QtTransparent)
252
253
 
253
254
  painter = QPainter(pixmap)
254
- painter.setRenderHint(QPainter.Antialiasing)
255
+ painter.setRenderHint(QtPaintAnitAlias)
255
256
  painter.fillPath(self._iconPath, QColor(red, green, blue))
256
257
  painter.end()
257
258
 
@@ -27,18 +27,18 @@ import json
27
27
  import logging
28
28
 
29
29
  from enum import Enum
30
+ from pathlib import Path
30
31
  from time import time
31
32
  from typing import TYPE_CHECKING
32
- from pathlib import Path
33
33
  from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
34
34
 
35
35
  from novelwriter import CONFIG
36
- from novelwriter.error import logException
37
36
  from novelwriter.common import isHandle, minmax
38
37
  from novelwriter.constants import nwFiles
39
38
  from novelwriter.core.document import NWDocument
40
39
  from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
41
40
  from novelwriter.core.spellcheck import UserDictionary
41
+ from novelwriter.error import logException
42
42
 
43
43
  if TYPE_CHECKING: # pragma: no cover
44
44
  from novelwriter.core.project import NWProject
@@ -287,6 +287,12 @@ class NWStorage:
287
287
  return self._runtimePath / "meta" / fileName
288
288
  return None
289
289
 
290
+ def getDocumentText(self, tHandle: str) -> str:
291
+ """Return the text of a document in a fast and efficient way."""
292
+ if isinstance(self._runtimePath, Path):
293
+ return NWDocument.quickReadText(self._runtimePath / "content", tHandle)
294
+ return ""
295
+
290
296
  def scanContent(self) -> list[str]:
291
297
  """Scan the content folder and return the handle of all files
292
298
  found in it. Files that do not match the pattern are ignored.
@@ -74,15 +74,9 @@ class ToHtml(Tokenizer):
74
74
  # Setters
75
75
  ##
76
76
 
77
- def setPreview(self, doComments: bool, doSynopsis: bool) -> None:
78
- """If we're using this class to generate markdown preview, we
79
- need to make a few changes to formatting, which is managed by
80
- these flags.
81
- """
82
- self._genMode = self.M_PREVIEW
83
- self._doKeywords = True
84
- self._doComments = doComments
85
- self._doSynopsis = doSynopsis
77
+ def setPreview(self, state: bool) -> None:
78
+ """Set to preview generator mode."""
79
+ self._genMode = self.M_PREVIEW if state else self.M_EXPORT
86
80
  return
87
81
 
88
82
  def setStyles(self, cssStyles: bool) -> None:
@@ -133,6 +127,8 @@ class ToHtml(Tokenizer):
133
127
  self.FMT_D_E: "</span>",
134
128
  self.FMT_U_B: "<u>",
135
129
  self.FMT_U_E: "</u>",
130
+ self.FMT_M_B: "<mark>",
131
+ self.FMT_M_E: "</mark>",
136
132
  }
137
133
  else:
138
134
  htmlTags = { # HTML5 (for export)
@@ -144,6 +140,8 @@ class ToHtml(Tokenizer):
144
140
  self.FMT_D_E: "</del>",
145
141
  self.FMT_U_B: "<span style='text-decoration: underline;'>",
146
142
  self.FMT_U_E: "</span>",
143
+ self.FMT_M_B: "<mark>",
144
+ self.FMT_M_E: "</mark>",
147
145
  }
148
146
 
149
147
  htmlTags[self.FMT_SUP_B] = "<sup>"
@@ -171,6 +169,8 @@ class ToHtml(Tokenizer):
171
169
  pStyle = None
172
170
  lines = []
173
171
 
172
+ tHandle = self._handle
173
+
174
174
  for tType, nHead, tText, tFormat, tStyle in self._tokens:
175
175
 
176
176
  # Replace < and > with HTML entities
@@ -229,8 +229,8 @@ class ToHtml(Tokenizer):
229
229
  else:
230
230
  hStyle = ""
231
231
 
232
- if self._linkHeaders:
233
- aNm = f"<a name='T{nHead:04d}'></a>"
232
+ if self._linkHeadings and tHandle:
233
+ aNm = f"<a name='{tHandle}:T{nHead:04d}'></a>"
234
234
  else:
235
235
  aNm = ""
236
236
 
@@ -252,10 +252,6 @@ class ToHtml(Tokenizer):
252
252
  tHead = tText.replace(nwHeadFmt.BR, "<br/>")
253
253
  lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")
254
254
 
255
- elif tType == self.T_UNNUM:
256
- tHead = tText.replace(nwHeadFmt.BR, "<br/>")
257
- lines.append(f"<{h2}{hStyle}>{aNm}{tHead}</{h2}>\n")
258
-
259
255
  elif tType == self.T_HEAD1:
260
256
  tHead = tText.replace(nwHeadFmt.BR, "<br/>")
261
257
  lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}</{h1}>\n")
@@ -296,12 +292,13 @@ class ToHtml(Tokenizer):
296
292
  lines.append(self._formatComments(tText))
297
293
 
298
294
  elif tType == self.T_KEYWORD and self._doKeywords:
299
- tTemp = f"<p{hStyle}>{self._formatKeywords(tText)}</p>\n"
295
+ tag, text = self._formatKeywords(tText)
296
+ kClass = f" class='meta meta-{tag}'" if tag else ""
297
+ tTemp = f"<p{kClass}{hStyle}>{text}</p>\n"
300
298
  lines.append(tTemp)
301
299
 
302
300
  self._result = "".join(lines)
303
- if self._genMode != self.M_PREVIEW:
304
- self._fullHTML.append(self._result)
301
+ self._fullHTML.append(self._result)
305
302
 
306
303
  return
307
304
 
@@ -364,13 +361,12 @@ class ToHtml(Tokenizer):
364
361
 
365
362
  def getStyleSheet(self) -> list[str]:
366
363
  """Generate a stylesheet for the current settings."""
367
- styles = []
368
364
  if not self._cssStyles:
369
- return styles
365
+ return []
370
366
 
371
367
  mScale = self._lineHeight/1.15
372
- textAlign = "justify" if self._doJustify else "left"
373
368
 
369
+ styles = []
374
370
  styles.append("body {{font-family: '{0:s}'; font-size: {1:d}pt;}}".format(
375
371
  self._textFont, self._textSize
376
372
  ))
@@ -380,7 +376,7 @@ class ToHtml(Tokenizer):
380
376
  "margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;"
381
377
  "}}"
382
378
  ).format(
383
- textAlign,
379
+ "justify" if self._doJustify else "left",
384
380
  round(100 * self._lineHeight),
385
381
  mScale * self._marginText[0],
386
382
  mScale * self._marginText[1],
@@ -445,6 +441,7 @@ class ToHtml(Tokenizer):
445
441
  ))
446
442
 
447
443
  styles.append("a {color: rgb(66, 113, 174);}")
444
+ styles.append("mark {background: rgb(255, 255, 166);}")
448
445
  styles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}")
449
446
  styles.append(".break {text-align: left;}")
450
447
  styles.append(".synopsis {font-style: italic;}")
@@ -475,11 +472,11 @@ class ToHtml(Tokenizer):
475
472
  sComm = self._localLookup("Comment")
476
473
  return f"<p class='comment'><strong>{sComm}:</strong> {text}</p>\n"
477
474
 
478
- def _formatKeywords(self, text: str) -> str:
475
+ def _formatKeywords(self, text: str) -> tuple[str, str]:
479
476
  """Apply HTML formatting to keywords."""
480
477
  valid, bits, _ = self._project.index.scanThis("@"+text)
481
478
  if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
482
- return ""
479
+ return "", ""
483
480
 
484
481
  result = f"<span class='tags'>{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:</span> "
485
482
  if len(bits) > 1:
@@ -494,6 +491,6 @@ class ToHtml(Tokenizer):
494
491
  else:
495
492
  result += ", ".join(f"<a href='#tag_{t}'>{t}</a>" for t in bits[1:])
496
493
 
497
- return result
494
+ return bits[0][1:], result
498
495
 
499
496
  # END Class ToHtml