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/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/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/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/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' '' )