diff --git a/CHANGELOG.md b/CHANGELOG.md
index 455d9449..b7c7a039 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,45 @@
# novelWriter Changelog
+## Version 2.7.2 [2025-06-24]
+
+### Release Notes
+
+This is a patch release that fixes several issues related to DocX and PDF manuscript documents,
+improves how line breaks, alignment and indentation is processed, and fixes some issues with
+displaying tags for novel documents on the user interface.
+
+### Detailed Changelog
+
+**Bugfixes**
+
+* Fixed an issue where escaped markup characters were not replaced properly in DocX and PDF
+ documents, and in previews. Issue #2410. PR #2411.
+* Fixed an issue where titles with line breaks in them would have page breaks applied to both lines
+ for preview and PDF documents. Issue #2415. PR #2416.
+* Fixed an issue where the justified text setting would not be properly applied to a paragraph in a
+ manuscript document if there was a line break in the paragraph, but single line breaks were set
+ to be ignored. Issue #2426. PR #2427.
+* Fixed an issue where the "Tag" field of the Outline View details panel remained blank even if a
+ tag was set for the novel document. Issue #2428. PR #2429.
+
+**Improvements**
+
+* When comments are enabled in the viewer, story comments should also be visible. A separate filter
+ button will be added for this in 2.8. PR #2420.
+* When a paragraph has line breaks in it, the alignment tag will now override first line
+ indentation even if the alignment tag is not on the first line. This is more consistent with the
+ alignment behaviour for multi-line paragraphs in general. Issue #2425. PR #2427.
+* Tags will now be shown in the Novel View tooltip pop-out under the triangle button, together with
+ all the other meta data collected about a document or heading. PR #2429.
+
+**Documentation**
+
+* The documentation on how alignment and first line indentation works in conjunction with
+ in-paragraph line breaks, the setting to keep or ignore such line breaks, and the forced line
+ break shortcode, has been improved. Issue #2425. PR #2427.
+
+----
+
## Version 2.7.1 [2025-06-09]
### Release Notes
diff --git a/CREDITS.md b/CREDITS.md
index b3c527a5..4c08b186 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -55,19 +55,33 @@ The following libraries are dependencies of novelWriter:
* **Enchant** by Dom Lachowicz
* **PyEnchant** by Dimitri Merejkowsky
-## Assets
+## Icon Themes
-Some of the assets bundled with novelWriter were adapted from the following sources:
+The following icon themes are bundles with novelWriter:
* **Material Symbols** icons by Google Inc (Apache 2.0)
* **Remix** icons by RemixIcon (Apache 2.0)
* **Font Awesome** icons by Fonticons Inc (CC BY 4.0)
-* **Tomorrow** themes by Chris Kempson (MIT License)
-* **Owl** themes by Sarah Drasner (MIT License)
-* **Solarized** themes by Ethan Schoonover (MIT License)
-* **Cyberpunk Night** theme by Anders Lemvigh (CC BY-SA 4.0)
-* **Dracula** theme by Zeno Rocha (MIT License)
-* **Snazzy Light** theme by Florian Reuschel (MIT License)
+
+## Colour Themes
+
+Some colour themes included with novelWriter were adapted from the following sources:
+
+* **Dracula** theme by Zeno Rocha
+* **Espresso** theme by Sainnhe Park and Antoine Cotten
+* **Noctis** themes by Liviu Schera, and contributors
+* **Owl** themes by Sarah Drasner
+* **Primer** themes by GitHub Inc and Myian
+* **Snazzy Light** theme by Florian Reuschel
+* **Solarized** themes by Ethan Schoonover
+* **Tango** themes by the Tango Desktop Project
+* **Tomorrow** themes by Chris Kempson
+
+Other themes were created for novelWriter:
+
+* **Blue Streak** themes by Veronica Berglyd Olsen
+* **Cyberpunk Night** theme by Anders Lemvigh
+* **LCARS** theme by Veronica Berglyd Olsen
## Fonts
diff --git a/docs/source/usage/advanced_formatting.rst b/docs/source/usage/advanced_formatting.rst
index 557db1a7..556d6f1f 100644
--- a/docs/source/usage/advanced_formatting.rst
+++ b/docs/source/usage/advanced_formatting.rst
@@ -56,6 +56,20 @@ activated by clicking the left-most icon button in the editor header.
.. versionadded:: 2.2
+.. _docs_usage_formatting_shortcodes_break:
+
+Forced Line Break
+-----------------
+
+Inserting ``[br]`` in the text will ensure a line break is always inserted in that place, even if
+you turn off **Preserve Hard Line Breaks** in your manuscript build settings.
+
+You can add a manual line break after it too, for a better visual representation in the editor, but
+keep in mind that this line break is removed before the text is processed, so the text on either
+side of the ``[br]`` shortcode will be considered as belonging to the same line. This can affect
+how alignment is treated. See :ref:`docs_usage_align_indent_forced` for more details.
+
+
.. _docs_usage_formatting_breaks:
Vertical Space and Page Breaks
diff --git a/docs/source/usage/alignment_and_indent.rst b/docs/source/usage/alignment_and_indent.rst
index 2494ea32..60949b9a 100644
--- a/docs/source/usage/alignment_and_indent.rst
+++ b/docs/source/usage/alignment_and_indent.rst
@@ -53,18 +53,33 @@ the entire paragraph. For the following text, all lines will be centred:
.. code-block:: md
- >> I am the very model of a modern Major-General
+ >> I am the very model of a modern Major-General <<
I've information vegetable, animal, and mineral
I know the kings of England, and I quote the fights historical
- From Marathon to Waterloo, in order categorical <<
+ From Marathon to Waterloo, in order categorical
+
+If you have multiple conflicting alignments on a paragraph, only one is applied. The order of
+precedence is:
+
+#. Left alignment
+#. Right alignment
+#. Centred text
+#. Justified text
+
+.. note::
+
+ It is strongly recommended that you keep the **Preserve Hard Line Breaks** setting enabled in
+ your manuscript build settings. This setting assumes all single line breaks in your text are
+ intended. Turning this off makes adding line breaks more complicated, but it is still possible.
+ See :ref:`docs_usage_align_indent_forced`.
Alignment with First Line Indent
================================
If you have first line indent enabled in your manuscript build settings, you probably want to
-disable it for text in verses. Adding any alignment tags will cause the first line indent to be
-switched off for that paragraph.
+disable it for text in verses. Adding any alignment tags on a paragraph will cause the first
+line indent to be switched off for that paragraph.
:bdg-info:`Example`
@@ -76,3 +91,35 @@ The following text will always be aligned against the left margin:
I've information vegetable, animal, and mineral
I know the kings of England, and I quote the fights historical
From Marathon to Waterloo, in order categorical
+
+
+.. _docs_usage_align_indent_forced:
+
+Alignment with Forced Line Breaks
+=================================
+
+If you turn off **Preserve Hard Line Breaks** in your manuscript build settings, you can still
+force line breaks in paragraphs using the ``[br]`` shortcode. For clarity in the text, you can add
+a line break after it as well. It doesn't result in two line breaks.
+
+Keep in mind that when the text is processed, the lines on either side of a ``[br]`` shortcode are
+combined, and any trailing hard line break is *ignored*. This means that when such a paragraph is
+processed, these line breaks count as the same line. This affects how alignment tags are handled.
+For instance, this text becomes centred instead of left aligned.
+
+.. code-block:: md
+
+ >> I am the very model of a modern Major-General[br]
+ I've information vegetable, animal, and mineral[br]
+ I know the kings of England, and I quote the fights historical[br]
+ From Marathon to Waterloo, in order categorical <<
+
+Since this is understood as one line, this is the only way you can actually centre this paragraph.
+
+.. caution::
+
+ Due to this difference in how text with ``[br]`` tags are processed, it is generally better to
+ stick with the **Preserve Hard Line Breaks** setting enabled. It ensures a better correspondence
+ between what you see in the editor and what output you get.
+
+See also :ref:`docs_usage_formatting_shortcodes_break`.
diff --git a/novelwriter/assets/text/credits_en.htm b/novelwriter/assets/text/credits_en.htm
index 33903616..c8d868a9 100644
--- a/novelwriter/assets/text/credits_en.htm
+++ b/novelwriter/assets/text/credits_en.htm
@@ -67,20 +67,38 @@ more contributions are listed on the project's Members page.
PyEnchant by Dimitri Merejkowsky
-Assets
+Icon Themes
-Some of the assets bundled with novelWriter were adapted from the following sources:
+The following icon themes are bundles with novelWriter:
- Material Symbols icons by Google Inc (Apache 2.0)
- Remix icons by RemixIcon (Apache 2.0)
- Font Awesome icons by Fonticons Inc (CC BY 4.0)
- - Tomorrow themes by Chris Kempson (MIT License)
- - Owl themes by Sarah Drasner (MIT License)
- - Solarized themes by Ethan Schoonover (MIT License)
- - Cyberpunk Night theme by Anders Lemvigh (CC BY-SA 4.0)
- - Dracula theme by Zeno Rocha (MIT License)
- - Snazzy Light theme by Florian Reuschel (MIT License)
+
+
+Colour Themes
+
+Some colour themes included with novelWriter were adapted from the following sources:
+
+
+ - Dracula theme by Zeno Rocha
+ - Espresso theme by Sainnhe Park and Antoine Cotten
+ - Noctis themes by Liviu Schera, and contributors
+ - Owl themes by Sarah Drasner
+ - Primer themes by GitHub Inc and Myian
+ - Snazzy Light theme by Florian Reuschel
+ - Solarized themes by Ethan Schoonover
+ - Tango themes by the Tango Desktop Project
+ - Tomorrow themes by Chris Kempson
+
+
+Other themes were created for novelWriter:
+
+
+ - Blue Streak themes by Veronica Berglyd Olsen
+ - Cyberpunk Night theme by Anders Lemvigh
+ - LCARS theme by Veronica Berglyd Olsen
Fonts
diff --git a/novelwriter/assets/themes/espresso.conf b/novelwriter/assets/themes/espresso.conf
new file mode 100644
index 00000000..74ff4b33
--- /dev/null
+++ b/novelwriter/assets/themes/espresso.conf
@@ -0,0 +1,76 @@
+[Main]
+name = Espresso
+mode = dark
+description = High contrast & vivid color scheme based on Monokai Pro
+author = Veronica Berglyd Olsen
+credit = Sainnhe Park and Antoine Cotten
+url = https://github.com/sainnhe/sonokai
+
+[Base]
+base = #2f2726
+default = #e5e4e2
+faded = #96817b
+red = #f86882
+orange = #f08d71
+yellow = #f0c66f
+green = #a6cd77
+cyan = #81d0c9
+blue = #89a8e0
+purple = #9fa0e1
+
+[Project]
+root = blue
+folder = yellow
+file = default
+title = green
+chapter = red
+scene = blue
+note = yellow
+active = green
+inactive = red
+disabled = faded
+
+[Palette]
+window = base:L125
+windowtext = default
+base = base
+alternatebase = #51403c
+text = default
+tooltipbase = yellow:L125
+tooltiptext = base
+button = base
+buttontext = default
+brighttext = base
+highlight = #4e5f80
+highlightedtext = default
+link = blue
+linkvisited = blue
+accent = blue
+
+[GUI]
+helptext = blue
+fadedtext = faded
+errortext = red
+
+[Syntax]
+background = base
+text = default
+line = #392e2c
+link = blue
+headertext = yellow:L125
+headertag = faded
+emphasis = orange
+dialog = yellow
+altdialog = orange
+note = yellow:L125
+hidden = faded
+shortcode = green
+keyword = red
+tag = green
+value = orange
+optional = yellow
+spellcheckline = red
+errorline = green
+replacetag = cyan
+modifier = blue
+texthighlight = yellow:108
diff --git a/novelwriter/assets/themes/noctis.conf b/novelwriter/assets/themes/noctis.conf
new file mode 100644
index 00000000..35820118
--- /dev/null
+++ b/novelwriter/assets/themes/noctis.conf
@@ -0,0 +1,108 @@
+[Main]
+name = Noctis
+mode = dark
+description = A dark theme with a well balanced blend of warm and cold medium contrast colors
+author = Veronica Berglyd Olsen
+credit = Liviu Schera, and contributors
+url = https://github.com/liviuschera/noctis
+
+##
+# Noctis Lux : themes/noctis.json
+##
+# colors
+# selection.background : #107684 -> Palette.highlight
+# sideBar.background : #041d20 -> Palette.window
+# editor.background : #052529 -> Base.base
+# editor.foreground : #b2cacd -> Base.default
+# editor.selectionBackground : #169fb144 -> Syntax.line, Syntax.texthighlight
+# textLink.foreground : #40d4e7 -> Palette.link, Syntax.link
+# input.background : #052529 -> Palette.button
+# input.foreground : #cdd3de -> Palette.buttontext
+# notifications.foreground : #cdd3de -> Palette.tooltiptext
+# notifications.background : #073940 -> Palette.tooltipbase
+##
+# tokenColors
+# COMMENT : #5b858b -> Base.faded
+# TEXT : #b2cacd -> Syntax.text, Syntax.optional
+# KEYWORD : #df769b -> Base.red
+# VARIABLE : #e4b781 -> Palette.accent
+# ANNOTATION : #d67e5c -> Syntax.note, Syntax.emphasis
+# CONSTANT : #d5971a -> Base.yellow
+# TAG : #e66533 -> Base.orange
+# STRING : #49e9a6 -> Syntax.headertext, Palette.accent
+# STRINGINTERPOLATED : #16b673 -> Base.green
+# NUMBER : #7060eb -> Base.purple
+# FUNCTION : #16a3b6 -> Base.cyan
+# SUPPORT : #49d6e9 -> Syntax.replacetag
+# MISC : #49ace9 -> Base.blue
+# INVALID : #e3541c -> GUI.errortext
+##
+
+[Base]
+base = #052529
+default = #b2cacd
+faded = #5b858b
+red = #df769b
+orange = #e66533
+yellow = #d5971a
+green = #16b673
+cyan = #16a3b6
+blue = #49ace9
+purple = #7060eb
+
+[Project]
+root = blue
+folder = yellow
+file = default
+title = green
+chapter = red
+scene = blue
+note = yellow
+active = green
+inactive = red
+disabled = faded
+
+[Palette]
+window = #041d20
+windowtext = default
+base = base
+alternatebase = base:L175
+text = default
+tooltipbase = #073940
+tooltiptext = #cdd3de
+button = base
+buttontext = #cdd3de
+brighttext = base
+highlight = #107684
+highlightedtext = default
+link = #40d4e7
+linkvisited = blue
+accent = #49e9a6
+
+[GUI]
+helptext = #e4b781
+fadedtext = faded
+errortext = #e3541c
+
+[Syntax]
+background = base
+text = #b2cacd
+line = #169fb132
+link = #40d4e7
+headertext = #49e9a6
+headertag = green:D125
+emphasis = #d67e5c
+dialog = green
+altdialog = blue
+note = #d67e5c
+hidden = faded
+shortcode = red
+keyword = yellow
+tag = blue
+value = red:D125
+optional = #b2cacd
+spellcheckline = red
+errorline = green
+replacetag = #49d6e9
+modifier = red
+texthighlight = #169fb144
diff --git a/novelwriter/assets/themes/noctis_lux.conf b/novelwriter/assets/themes/noctis_lux.conf
new file mode 100644
index 00000000..ddf34244
--- /dev/null
+++ b/novelwriter/assets/themes/noctis_lux.conf
@@ -0,0 +1,108 @@
+[Main]
+name = Noctis Lux
+mode = light
+description = A light theme with a well balanced blend of warm and cold medium contrast colors
+author = Veronica Berglyd Olsen
+credit = Liviu Schera, and contributors
+url = https://github.com/liviuschera/noctis
+
+##
+# Noctis Lux : themes/lux.json
+##
+# colors
+# selection.background : #169fb155 -> Palette.highlight
+# sideBar.background : #f9f1e1 -> Palette.window
+# editor.background : #fef8ec -> Base.base
+# editor.foreground : #005661 -> Base.default
+# editor.selectionBackground : #ade2eb77 -> Syntax.line, Syntax.texthighlight
+# textLink.foreground : #00c6e0 -> Palette.link, Syntax.link
+# input.background : #fef8ec -> Palette.button
+# input.foreground : #6a7a7c -> Palette.buttontext
+# notifications.foreground : #005661 -> Palette.tooltiptext
+# notifications.background : #f2edde -> Palette.tooltipbase
+##
+# tokenColors
+# COMMENT : #8ca6a6 -> Base.faded
+# TEXT : #004d57 -> Syntax.text, Syntax.optional
+# KEYWORD : #ff5792 -> Base.red
+# VARIABLE : #fa8900 -> Palette.accent
+# ANNOTATION : #b3694d -> Syntax.note, Syntax.emphasis
+# CONSTANT : #a88c00 -> Base.yellow
+# TAG : #e64100 -> Base.orange
+# STRING : #00b368 -> Syntax.headertext, Palette.accent
+# STRINGINTERPOLATED : #009456 -> Base.green
+# NUMBER : #5842ff -> Base.purple
+# FUNCTION : #0095a8 -> Base.cyan
+# SUPPORT : #00bdd6 -> Syntax.replacetag
+# MISC : #0094f0 -> Base.blue
+# INVALID : #ff530f -> GUI.errortext
+##
+
+[Base]
+base = #fef8ec
+default = #005661
+faded = #8ca6a6
+red = #ff5792
+orange = #e64100
+yellow = #a88c00
+green = #009456
+cyan = #0095a8
+blue = #0094f0
+purple = #5842ff
+
+[Project]
+root = blue
+folder = yellow
+file = default
+title = green
+chapter = red
+scene = blue
+note = yellow
+active = green
+inactive = red
+disabled = faded
+
+[Palette]
+window = #f9f1e1
+windowtext = default
+base = base
+alternatebase = base:D115
+text = default
+tooltipbase = #f2edde
+tooltiptext = #005661
+button = base
+buttontext = #6a7a7c
+brighttext = base
+highlight = #169fb155
+highlightedtext = default
+link = #00c6e0
+linkvisited = blue
+accent = #00b368
+
+[GUI]
+helptext = #fa8900
+fadedtext = faded
+errortext = #ff530f
+
+[Syntax]
+background = base
+text = #004d57
+line = #ade2eb56
+link = #00c6e0
+headertext = #00b368
+headertag = green:D125
+emphasis = #b3694d
+dialog = green
+altdialog = blue
+note = #b3694d
+hidden = faded
+shortcode = red
+keyword = yellow
+tag = blue
+value = red:D125
+optional = #004d57
+spellcheckline = red
+errorline = green
+replacetag = #00bdd6
+modifier = red
+texthighlight = #ade2eb77
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index d8e9c8e4..07933690 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -708,17 +708,19 @@ class Index:
return 0, 0, 0
def getReferences(self, tHandle: str, sTitle: str | None = None) -> dict[str, list[str]]:
- """Extract all references made in a file, and optionally title
- section.
+ """Extract all tags and references made in a file, and
+ optionally title section.
"""
- tRefs = {x: [] for x in nwKeyWords.VALID_KEYS}
+ refs = {x: [] for x in nwKeyWords.VALID_KEYS}
for rTitle, hItem in self._itemIndex.iterItemHeaders(tHandle):
if sTitle is None or sTitle == rTitle:
for aTag, refTypes in hItem.references.items():
for refType in refTypes:
- if refType in tRefs:
- tRefs[refType].append(self._tagsIndex.tagName(aTag))
- return tRefs
+ if refType in refs:
+ refs[refType].append(self._tagsIndex.tagName(aTag))
+ if tag := hItem.tag:
+ refs[nwKeyWords.TAG_KEY] = [self._tagsIndex.tagName(tag)]
+ return refs
def getReferenceForHeader(self, tHandle: str, nHead: int, keyClass: str) -> list[str]:
"""Get the display names for a tags class for insertion into a
diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py
index 070558ce..b74ce4c4 100644
--- a/novelwriter/core/indexdata.py
+++ b/novelwriter/core/indexdata.py
@@ -342,20 +342,26 @@ class IndexHeading:
##
def getReferences(self) -> dict[str, list[str]]:
- """Extract all references for this heading."""
+ """Extract all tags and references for this heading."""
refs = {x: [] for x in nwKeyWords.VALID_KEYS}
for tag, types in self._refs.items():
for keyword in types:
if keyword in refs and (name := self._cache.tags.tagName(tag)):
refs[keyword].append(name)
+ if name := self._cache.tags.tagName(self._tag):
+ refs[nwKeyWords.TAG_KEY] = [name]
return refs
def getReferencesByKeyword(self, keyword: str) -> list[str]:
"""Extract all references for this heading."""
refs = []
- for tag, types in self._refs.items():
- if keyword in types and (name := self._cache.tags.tagName(tag)):
+ if keyword == nwKeyWords.TAG_KEY:
+ if name := self._cache.tags.tagName(self._tag):
refs.append(name)
+ else:
+ for tag, types in self._refs.items():
+ if keyword in types and (name := self._cache.tags.tagName(tag)):
+ refs.append(name)
return refs
##
diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py
index 176c4873..b4cce890 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -893,31 +893,21 @@ class Tokenizer(ABC):
if nBlock[0] != BlockTyp.TEXT:
# Next block is not text, so we add the buffer to blocks
nLines = len(pLines)
- cStyle = pLines[0][4]
- if firstIndent and not (self._noIndent or cStyle & BlockFmt.ALIGNED):
- # If paragraph indentation is enabled, not temporarily
- # turned off, and the block is not aligned, we add the
- # text indentation flag
- cStyle |= BlockFmt.IND_T
+ tFmt: T_Formats = []
+ pTxt = ""
+ cStyle = BlockFmt.NONE
if nLines == 1:
- # The paragraph contains a single line, so we just save
- # that directly to the blocks list. If justify is
- # enabled, and there is no alignment, we apply it.
- if doJustify and not cStyle & BlockFmt.ALIGNED:
- cStyle |= BlockFmt.JUSTIFY
-
+ # The paragraph contains a single line
+ tFmt = pLines[0][3]
pTxt = pLines[0][2].translate(transMapB)
- sBlocks.append((
- BlockTyp.TEXT, pLines[0][1], pTxt, pLines[0][3], cStyle
- ))
+ cStyle = pLines[0][4]
elif nLines > 1:
# The paragraph contains multiple lines, so we need to
# join them according to the line break policy, and
# recompute all the formatting markers
tTxt = ""
- tFmt: T_Formats = []
for aBlock in pLines:
tLen = len(tTxt)
tTxt += f"{aBlock[2]}{lineSep}"
@@ -925,6 +915,18 @@ class Tokenizer(ABC):
cStyle |= aBlock[4]
pTxt = tTxt[:-1].translate(transMapB)
+
+ if nLines:
+ isAligned = cStyle & BlockFmt.ALIGNED
+ if firstIndent and not (self._noIndent or isAligned):
+ # If paragraph indentation is enabled, not temporarily
+ # turned off, and the block is not aligned, we add the
+ # text indentation flag
+ cStyle |= BlockFmt.IND_T
+
+ if doJustify and not isAligned:
+ cStyle |= BlockFmt.JUSTIFY
+
sBlocks.append((
BlockTyp.TEXT, pLines[0][1], pTxt, tFmt, cStyle
))
diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py
index 752c6d39..86913b61 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -593,6 +593,7 @@ class GuiNovelTree(NTreeView):
lines = []
if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
tags = head.getReferences()
+ appendTags(tags, nwKeyWords.TAG_KEY, lines)
appendTags(tags, nwKeyWords.POV_KEY, lines)
appendTags(tags, nwKeyWords.FOCUS_KEY, lines)
appendTags(tags, nwKeyWords.CHAR_KEY, lines)
diff --git a/pyproject.toml b/pyproject.toml
index 9263eb81..3610e012 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,11 @@ authors = [
]
description = "A plain text editor for planning and writing novels"
readme = {file = "setup/description_pypi.md", content-type = "text/markdown"}
-license = {text = "GPL-3.0-or-later"}
+license = "GPL-3.0-or-later AND Apache-2.0 AND CC-BY-4.0"
+license-files = [
+ "LICENSE.md",
+ "setup/LICENSE-Apache-2.0.txt",
+]
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd
index 12f0d4e0..11e59489 100644
--- a/sample/content/636b6aa9b697b.nwd
+++ b/sample/content/636b6aa9b697b.nwd
@@ -1,10 +1,11 @@
%%~name: Making a Scene
%%~path: 6a2d6d5f4f401/636b6aa9b697b
%%~kind: NOVEL/DOCUMENT
-%%~hash: b85f815702763f58926edd72a2eeed1df5e1cbc9
+%%~hash: c1a75b18145e49e5e71b5284f66a261207da37d0
%%~date: Unknown/2025-06-16 12:49:10
### Making a Scene
+@tag: Scene
@pov: Jane
@char: John, Jane
@location: Earth
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index 587802ef..812795c4 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Sample Project
Jane Smith
@@ -58,7 +58,7 @@
Chapter One
-
-
+
Making a Scene
-
diff --git a/setup/LICENSE-Apache-2.0.txt b/setup/LICENSE-Apache-2.0.txt
new file mode 100644
index 00000000..7a4a3ea2
--- /dev/null
+++ b/setup/LICENSE-Apache-2.0.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index dca4575a..e86b8dc0 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -695,6 +695,7 @@ def testCoreIndex_ExtractData(nwGUI, fncPath, mockRnd):
))
assert index.scanText(nHandle, (
"# Hello World!\n"
+ "@tag: Scene\n"
"@pov: Jane\n"
"@char: Jane, John\n\n"
"% this is a comment\n\n"
@@ -749,11 +750,13 @@ def testCoreIndex_ExtractData(nwGUI, fncPath, mockRnd):
# Look up an invalid handle
refs = index.getReferences("Not a handle")
+ assert refs["@tag"] == []
assert refs["@pov"] == []
assert refs["@char"] == []
# The novel file should now refer to Jane as @pov and @char
refs = index.getReferences(nHandle)
+ assert refs["@tag"] == ["Scene"]
assert refs["@pov"] == ["Jane"]
assert refs["@char"] == ["Jane", "John"]
@@ -791,7 +794,7 @@ def testCoreIndex_ExtractData(nwGUI, fncPath, mockRnd):
# getKeyWordTags
# ==============
- assert index.getKeyWordTags("@mention") == ["Jane", "John"]
+ assert index.getKeyWordTags("@mention") == ["Jane", "John", "Scene"]
assert index.getKeyWordTags("@char") == ["Jane", "John"]
assert index.getKeyWordTags("@plot") == []
assert index.getKeyWordTags("@tag") == []
diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py
index 5bf45c8a..d4797aee 100644
--- a/tests/test_core/test_core_indexdata.py
+++ b/tests/test_core/test_core_indexdata.py
@@ -267,6 +267,7 @@ def testCoreIndexData_IndexHeadingReferences():
head = IndexHeading(cache, "T0001")
# Add some references
+ head.setTag("Scene")
head.addReference("Jane", "@pov")
head.addReference("Jane", "@char")
head.addReference("John", "@char")
@@ -290,6 +291,7 @@ def testCoreIndexData_IndexHeadingReferences():
}
# Set names
+ cache.tags.add("Scene", "Scene", "0000000000000", "T00001", "NOVEL")
cache.tags.add("Jane", "Jane", "0000000000000", "T00001", "CHARACTER")
cache.tags.add("John", "John", "0000000000000", "T00001", "CHARACTER")
cache.tags.add("Main", "Main", "0000000000000", "T00001", "PLOT")
@@ -301,7 +303,7 @@ def testCoreIndexData_IndexHeadingReferences():
"@plot": ["Main"],
"@object": ["Gun"],
"@story": [],
- "@tag": [],
+ "@tag": ["Scene"],
"@focus": [],
"@custom": [],
"@time": [],
@@ -316,7 +318,7 @@ def testCoreIndexData_IndexHeadingReferences():
assert head.getReferencesByKeyword("@plot") == ["Main"]
assert head.getReferencesByKeyword("@object") == ["Gun"]
assert head.getReferencesByKeyword("@story") == []
- assert head.getReferencesByKeyword("@tag") == []
+ assert head.getReferencesByKeyword("@tag") == ["Scene"]
assert head.getReferencesByKeyword("@focus") == []
assert head.getReferencesByKeyword("@custom") == []
assert head.getReferencesByKeyword("@time") == []
diff --git a/tests/test_formats/test_fmt_tohtml.py b/tests/test_formats/test_fmt_tohtml.py
index 02a31451..c3bd64a7 100644
--- a/tests/test_formats/test_fmt_tohtml.py
+++ b/tests/test_formats/test_fmt_tohtml.py
@@ -325,6 +325,54 @@ def testFmtToHtml_ConvertMeta(mockGUI):
)
+@pytest.mark.core
+def testFmtToHtml_Alignment(mockGUI):
+ """Test paragraph alignment in the ToHtml class."""
+ project = NWProject()
+ html = ToHtml(project)
+ html.initDocument()
+
+ # Left
+ html._text = "This is text <<\nspanning multiple\nlines"
+ html.tokenizeText()
+ html.doConvert()
+ assert html._pages[-1] == (
+ "
This is text
spanning multiple
lines
\n"
+ )
+
+ # Right
+ html._text = ">> This is text\nspanning multiple\nlines"
+ html.tokenizeText()
+ html.doConvert()
+ assert html._pages[-1] == (
+ "This is text
spanning multiple
lines
\n"
+ )
+
+ # Centre
+ html._text = ">> This is text <<\nspanning multiple\nlines"
+ html.tokenizeText()
+ html.doConvert()
+ assert html._pages[-1] == (
+ "This is text
spanning multiple
lines
\n"
+ )
+
+ # Left before Right
+ html._text = ">> This is text\nspanning multiple <<\nlines"
+ html.tokenizeText()
+ html.doConvert()
+ assert html._pages[-1] == (
+ "This is text
spanning multiple
lines
\n"
+ )
+
+ # Right before Centre
+ html._text = ">> This is text <<\n>> spanning multiple\nlines"
+ html.tokenizeText()
+ html.doConvert()
+ assert html._pages[-1] == (
+ "This is text
spanning multiple
lines
\n"
+ )
+
+
@pytest.mark.core
def testFmtToHtml_Dialog(mockGUI):
"""Test paragraph formats in the ToHtml class."""
diff --git a/tests/test_formats/test_fmt_tokenizer.py b/tests/test_formats/test_fmt_tokenizer.py
index 4f65e44c..63c9cab0 100644
--- a/tests/test_formats/test_fmt_tokenizer.py
+++ b/tests/test_formats/test_fmt_tokenizer.py
@@ -1118,6 +1118,80 @@ def testFmtToken_Paragraphs(mockGUI):
]
+@pytest.mark.core
+def testFmtToken_BreakAlignIndent(mockGUI):
+ """Test the splitting of paragraphs with alignment."""
+ project = NWProject()
+ tokens = BareTokenizer(project)
+ tokens._handle = TMH
+
+ for text in [
+ "This is text <<\nspanning multiple\nlines",
+ "This is text\nspanning multiple <<\nlines",
+ "This is text\nspanning multiple\nlines <<",
+ ]:
+ # Preserve Breaks
+ tokens.setKeepLineBreaks(True)
+ tokens._text = text
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text\nspanning multiple\nlines", [], BlockFmt.LEFT),
+ ]
+
+ # Don't Preserve Breaks
+ tokens.setKeepLineBreaks(False)
+ tokens._text = text
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text spanning multiple lines", [], BlockFmt.LEFT),
+ ]
+
+ # With Justify
+ # This should disable justify
+ tokens.setKeepLineBreaks(True)
+ tokens.setJustify(True)
+ tokens._text = text
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text\nspanning multiple\nlines", [], BlockFmt.LEFT),
+ ]
+
+ # With Indent
+ # This should disable indent
+ tokens.setKeepLineBreaks(True)
+ tokens.setFirstLineIndent(True, 1.0, False)
+ tokens._text = text
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text\nspanning multiple\nlines", [], BlockFmt.LEFT),
+ ]
+
+
+@pytest.mark.core
+def testFmtToken_BreakJustify(mockGUI):
+ """Test the of processing of justify with breaks."""
+ project = NWProject()
+ tokens = BareTokenizer(project)
+ tokens._handle = TMH
+ tokens.setJustify(True)
+
+ # Applied to all lines when breaks are preserved
+ tokens._text = "This is text\nspanning multiple\nlines"
+ tokens.setKeepLineBreaks(True)
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text\nspanning multiple\nlines", [], BlockFmt.JUSTIFY),
+ ]
+
+ # Turning off breaks should make no difference (see issue #2426)
+ tokens._text = "This is text\nspanning multiple\nlines"
+ tokens.setKeepLineBreaks(False)
+ tokens.tokenizeText()
+ assert tokens._blocks == [
+ (BlockTyp.TEXT, "", "This is text spanning multiple lines", [], BlockFmt.JUSTIFY),
+ ]
+
+
@pytest.mark.core
def testFmtToken_TextFormat(mockGUI):
"""Test the tokenization of text formats in the Tokenizer class."""
diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py
index 70b72874..eded1eeb 100644
--- a/tests/test_formats/test_fmt_toodt.py
+++ b/tests/test_formats/test_fmt_toodt.py
@@ -733,7 +733,7 @@ def testFmtToOdt_ConvertParagraphs(mockGUI):
''
'Scene'
'Regular paragraph'
- 'withbreak'
+ 'withbreak'
'Left Align'
''
)