Skip to content

Commit

Permalink
Add forced line breaks feature (#2063)
Browse files Browse the repository at this point in the history
  • Loading branch information
vkbo authored Oct 24, 2024
2 parents f77cd1c + 6b01bd7 commit 41e5152
Show file tree
Hide file tree
Showing 28 changed files with 487 additions and 251 deletions.
2 changes: 1 addition & 1 deletion novelwriter/assets/i18n/project_en_GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"Short Description": "Short Description",
"Footnotes": "Footnotes",
"Comment": "Comment",
"Note": "Note",
"Notes": "Notes",
"Tag": "Tag",
"Point of View": "Point of View",
"Focus": "Focus",
Expand Down
4 changes: 3 additions & 1 deletion novelwriter/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ class nwConst:
class nwRegEx:

WORDS = r"\b[^\s\-\+\/–—\[\]:]+\b"
BREAK = r"(?i)(?<!\\)(\[br\]\n?)"
FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_EB = r"(?<![\w\\])(\*{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_ST = r"(?<![\w\\])(~{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:b|i|s|u|m|sup|sub)\])"
FMT_SC = r"(?i)(?<!\\)(\[(?:b|/b|i|/i|s|/s|u|/u|m|/m|sup|/sup|sub|/sub|br)\])"
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote):)(.+?)(?<!\\)(\])"


Expand All @@ -84,6 +85,7 @@ class nwShortcode:
SUP_C = "[/sup]"
SUB_O = "[sub]"
SUB_C = "[/sub]"
BREAK = "[br]"

FOOTNOTE_B = "[footnote:"

Expand Down
2 changes: 1 addition & 1 deletion novelwriter/core/buildsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"headings.centerPart": (bool, True),
"headings.centerChapter": (bool, False),
"headings.centerScene": (bool, False),
"headings.breakTitle": (bool, True),
"headings.breakTitle": (bool, False),
"headings.breakPart": (bool, True),
"headings.breakChapter": (bool, True),
"headings.breakScene": (bool, False),
Expand Down
4 changes: 2 additions & 2 deletions novelwriter/core/docbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,8 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
self._build.getBool("headings.hideSection")
)
bldObj.setTitleStyle(
self._build.getBool("headings.centerPart"),
self._build.getBool("headings.breakPart")
self._build.getBool("headings.centerTitle"),
self._build.getBool("headings.breakTitle")
)
bldObj.setPartitionStyle(
self._build.getBool("headings.centerPart"),
Expand Down
1 change: 1 addition & 0 deletions novelwriter/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class nwDocInsert(Enum):
VSPACE_M = 9
LIPSUM = 10
FOOTNOTE = 11
LINE_BRK = 12


class nwView(Enum):
Expand Down
15 changes: 5 additions & 10 deletions novelwriter/formats/todocx.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,24 +255,19 @@ def doConvert(self) -> None:
self._processFragments(par, S_NORM, tText, tFormat)

elif tType == BlockTyp.TITLE:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_TITLE, tHead, tFormat)
self._processFragments(par, S_TITLE, tText, tFormat)

elif tType == BlockTyp.HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD1, tHead, tFormat)
self._processFragments(par, S_HEAD1, tText, tFormat)

elif tType == BlockTyp.HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD2, tHead, tFormat)
self._processFragments(par, S_HEAD2, tText, tFormat)

elif tType == BlockTyp.HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD3, tHead, tFormat)
self._processFragments(par, S_HEAD3, tText, tFormat)

elif tType == BlockTyp.HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD4, tHead, tFormat)
self._processFragments(par, S_HEAD4, tText, tFormat)

elif tType == BlockTyp.SEP:
self._processFragments(par, S_SEP, tText)
Expand Down
12 changes: 6 additions & 6 deletions novelwriter/formats/tohtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from time import time

from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwHeadFmt, nwHtmlUnicode
from novelwriter.constants import nwHtmlUnicode
from novelwriter.core.project import NWProject
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, stripEscape
from novelwriter.formats.tokenizer import Tokenizer
Expand Down Expand Up @@ -211,23 +211,23 @@ def doConvert(self) -> None:
lines.append(f"<p{hStyle}>{self._formatText(tText, tFmt)}</p>\n")

elif tType == BlockTyp.TITLE:
tHead = tText.replace(nwHeadFmt.BR, "<br>")
tHead = tText.replace("\n", "<br>")
lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")

elif tType == BlockTyp.HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "<br>")
tHead = tText.replace("\n", "<br>")
lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}</{h1}>\n")

elif tType == BlockTyp.HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "<br>")
tHead = tText.replace("\n", "<br>")
lines.append(f"<{h2}{hStyle}>{aNm}{tHead}</{h2}>\n")

elif tType == BlockTyp.HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "<br>")
tHead = tText.replace("\n", "<br>")
lines.append(f"<{h3}{hStyle}>{aNm}{tHead}</{h3}>\n")

elif tType == BlockTyp.HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "<br>")
tHead = tText.replace("\n", "<br>")
lines.append(f"<{h4}{hStyle}>{aNm}{tHead}</{h4}>\n")

elif tType == BlockTyp.SEP:
Expand Down
94 changes: 52 additions & 42 deletions novelwriter/formats/tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ class ComStyle(NamedTuple):
textClass: str = ""


B_EMPTY: T_Block = (BlockTyp.EMPTY, "", "", [], BlockFmt.NONE)
COMMENT_STYLE = {
nwComment.PLAIN: ComStyle("Comment", "comment", "comment"),
nwComment.IGNORE: ComStyle(),
Expand All @@ -67,13 +66,12 @@ class ComStyle(NamedTuple):
nwComment.COMMENT: ComStyle(),
nwComment.STORY: ComStyle("", "modifier", "note"),
}

# Lookups
HEADINGS = [BlockTyp.TITLE, BlockTyp.HEAD1, BlockTyp.HEAD2, BlockTyp.HEAD3, BlockTyp.HEAD4]
SKIP_INDENT = [
BlockTyp.TITLE, BlockTyp.HEAD1, BlockTyp.HEAD2, BlockTyp.HEAD2, BlockTyp.HEAD3,
BlockTyp.HEAD4, BlockTyp.SEP, BlockTyp.SKIP,
]
B_EMPTY: T_Block = (BlockTyp.EMPTY, "", "", [], BlockFmt.NONE)


class Tokenizer(ABC):
Expand Down Expand Up @@ -182,8 +180,6 @@ def __init__(self, project: NWProject) -> None:
(REGEX_PATTERNS.markdownBold, [0, TextFmt.B_B, 0, TextFmt.B_E]),
(REGEX_PATTERNS.markdownStrike, [0, TextFmt.D_B, 0, TextFmt.D_E]),
]
self._rxShortCodes = REGEX_PATTERNS.shortcodePlain
self._rxShortCodeVals = REGEX_PATTERNS.shortcodeValue

self._shortCodeFmt = {
nwShortcode.ITALIC_O: TextFmt.I_B, nwShortcode.ITALIC_C: TextFmt.I_E,
Expand Down Expand Up @@ -456,20 +452,22 @@ def addRootHeading(self, tHandle: str) -> None:
self._text = ""
self._handle = None

if (tItem := self._project.tree[tHandle]) and tItem.isRootType():
if (item := self._project.tree[tHandle]) and item.isRootType():
self._handle = tHandle
style = BlockFmt.CENTRE
if self._isFirst:
textAlign = BlockFmt.CENTRE
self._isFirst = False
else:
textAlign = BlockFmt.PBB | BlockFmt.CENTRE
style |= BlockFmt.PBB

trNotes = self._localLookup("Notes")
title = f"{trNotes}: {tItem.itemName}"
self._blocks = []
self._blocks.append((
BlockTyp.TITLE, f"{self._handle}:T0001", title, [], textAlign
))
title = item.itemName
if not item.isNovelLike():
notes = self._localLookup("Notes")
title = f"{notes}: {title}"

self._blocks = [(
BlockTyp.TITLE, f"{self._handle}:T0001", title, [], style
)]
if self._keepRaw:
self._raw.append(f"#! {title}\n\n")

Expand Down Expand Up @@ -523,25 +521,30 @@ def tokenizeText(self) -> None:
isNovel = self._isNovel
keepRaw = self._keepRaw
doJustify = self._doJustify
keepBreaks = self._keepBreaks
indentFirst = self._indentFirst
firstIndent = self._firstIndent

if self._isNovel:
self._hFormatter.setHandle(self._handle)

# Replace all instances of [br] with a placeholder character
text = REGEX_PATTERNS.lineBreak.sub("\uffff", self._text)

nHead = 0
breakNext = False
tmpMarkdown = []
rawText = []
tHandle = self._handle or ""
tBlocks: list[T_Block] = [B_EMPTY]
for aLine in self._text.splitlines():
for bLine in text.splitlines():
aLine = bLine.replace("\uffff", "") # Remove placeholder characters
sLine = aLine.strip().lower()

# Check for blank lines
if not sLine:
tBlocks.append(B_EMPTY)
if keepRaw:
tmpMarkdown.append("\n")
rawText.append("\n")
continue

if breakNext:
Expand Down Expand Up @@ -607,13 +610,13 @@ def tokenizeText(self) -> None:
BlockTyp.COMMENT, "", tLine, tFmt, sAlign
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif cStyle == nwComment.FOOTNOTE:
tLine, tFmt = self._extractFormats(cText, skip=TextFmt.FNOTE)
self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt)
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif aLine.startswith("@"):
# Keywords
Expand All @@ -628,7 +631,7 @@ def tokenizeText(self) -> None:
BlockTyp.KEYWORD, tTag[1:], tLine, tFmt, sAlign
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif aLine.startswith(("# ", "#! ")):
# Title or Partition Headings
Expand Down Expand Up @@ -664,7 +667,7 @@ def tokenizeText(self) -> None:
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif aLine.startswith(("## ", "##! ")):
# (Unnumbered) Chapter Headings
Expand Down Expand Up @@ -699,7 +702,7 @@ def tokenizeText(self) -> None:
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif aLine.startswith(("### ", "###! ")):
# (Alternative) Scene Headings
Expand Down Expand Up @@ -740,7 +743,7 @@ def tokenizeText(self) -> None:
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

elif aLine.startswith("#### "):
# Section Headings
Expand Down Expand Up @@ -770,7 +773,7 @@ def tokenizeText(self) -> None:
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

else:
# Text Lines
Expand All @@ -786,19 +789,19 @@ def tokenizeText(self) -> None:
alnRight = False
indLeft = False
indRight = False
if aLine.startswith(">>"):
if bLine.startswith(">>"):
alnRight = True
aLine = aLine[2:].lstrip(" ")
elif aLine.startswith(">"):
bLine = bLine[2:].lstrip(" ")
elif bLine.startswith(">"):
indLeft = True
aLine = aLine[1:].lstrip(" ")
bLine = bLine[1:].lstrip(" ")

if aLine.endswith("<<"):
if bLine.endswith("<<"):
alnLeft = True
aLine = aLine[:-2].rstrip(" ")
elif aLine.endswith("<"):
bLine = bLine[:-2].rstrip(" ")
elif bLine.endswith("<"):
indRight = True
aLine = aLine[:-1].rstrip(" ")
bLine = bLine[:-1].rstrip(" ")

if alnLeft and alnRight:
sAlign |= BlockFmt.CENTRE
Expand All @@ -813,12 +816,12 @@ def tokenizeText(self) -> None:
sAlign |= BlockFmt.IND_R

# Process formats
tLine, tFmt = self._extractFormats(aLine, hDialog=isNovel)
tLine, tFmt = self._extractFormats(bLine, hDialog=isNovel)
tBlocks.append((
BlockTyp.TEXT, "", tLine, tFmt, sAlign
))
if keepRaw:
tmpMarkdown.append(f"{aLine}\n")
rawText.append(f"{aLine}\n")

# If we have content, turn off the first page flag
if self._isFirst and len(tBlocks) > 1:
Expand All @@ -834,8 +837,8 @@ def tokenizeText(self) -> None:
# Always add an empty line at the end of the file
tBlocks.append(B_EMPTY)
if keepRaw:
tmpMarkdown.append("\n")
self._raw.append("".join(tmpMarkdown))
rawText.append("\n")
self._raw.append("".join(rawText))

# Second Pass
# ===========
Expand All @@ -844,7 +847,7 @@ def tokenizeText(self) -> None:
# It also ensures that there isn't paragraph spacing between
# meta data lines for formats that have spacing.

lineSep = "\n" if self._keepBreaks else " "
lineSep = "\n" if keepBreaks else " "

pLines: list[T_Block] = []
sBlocks: list[T_Block] = []
Expand Down Expand Up @@ -894,9 +897,12 @@ def tokenizeText(self) -> None:
# enabled, and there is no alignment, we apply it.
if doJustify and not cStyle & BlockFmt.ALIGNED:
cStyle |= BlockFmt.JUSTIFY

pTxt = pLines[0][2].replace("\uffff", "\n")
sBlocks.append((
BlockTyp.TEXT, pLines[0][1], pLines[0][2], pLines[0][3], cStyle
BlockTyp.TEXT, pLines[0][1], pTxt, pLines[0][3], cStyle
))

elif nLines > 1:
# The paragraph contains multiple lines, so we need to
# join them according to the line break policy, and
Expand All @@ -907,8 +913,11 @@ def tokenizeText(self) -> None:
tLen = len(tTxt)
tTxt += f"{aBlock[2]}{lineSep}"
tFmt.extend((p+tLen, fmt, key) for p, fmt, key in aBlock[3])
cStyle |= aBlock[4]

pTxt = tTxt[:-1].replace("\uffff", "\n")
sBlocks.append((
BlockTyp.TEXT, pLines[0][1], tTxt[:-1], tFmt, cStyle
BlockTyp.TEXT, pLines[0][1], pTxt, tFmt, cStyle
))

# Reset buffer and make sure text indent is on for next pass
Expand Down Expand Up @@ -1136,12 +1145,12 @@ def _extractFormats(
# Post-process text and format
result = text
formats = []
for pos, end, fmt, key in reversed(sorted(temp, key=lambda x: x[0])):
for pos, end, fmt, meta in reversed(sorted(temp, key=lambda x: x[0])):
if fmt > 0:
if end > pos:
result = result[:pos] + result[end:]
formats = [(p+pos-end if p > pos else p, f, k) for p, f, k in formats]
formats.insert(0, (pos, fmt, key))
formats = [(p+pos-end if p > pos else p, f, m) for p, f, m in formats]
formats.insert(0, (pos, fmt, meta))

return result, formats

Expand Down Expand Up @@ -1187,6 +1196,7 @@ def resetScene(self) -> None:
def apply(self, hFormat: str, text: str, nHead: int) -> str:
"""Apply formatting to a specific heading."""
hFormat = hFormat.replace(nwHeadFmt.TITLE, text)
hFormat = hFormat.replace(nwHeadFmt.BR, "\n")
hFormat = hFormat.replace(nwHeadFmt.CH_NUM, str(self._chCount))
hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scChCount))
hFormat = hFormat.replace(nwHeadFmt.SC_ABS, str(self._scAbsCount))
Expand Down
Loading

0 comments on commit 41e5152

Please sign in to comment.