diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index ac336628..d9221e29 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -37,28 +37,35 @@ from novelwriter.types import FONT_STYLE, FONT_WEIGHTS
logger = logging.getLogger(__name__)
-HTML5_TAGS = {
- Tokenizer.FMT_B_B: "",
- Tokenizer.FMT_B_E: "",
- Tokenizer.FMT_I_B: "",
- Tokenizer.FMT_I_E: "",
- Tokenizer.FMT_D_B: "",
- Tokenizer.FMT_D_E: "",
- Tokenizer.FMT_U_B: "",
- Tokenizer.FMT_U_E: "",
- Tokenizer.FMT_M_B: "",
- Tokenizer.FMT_M_E: "",
- Tokenizer.FMT_SUP_B: "",
- Tokenizer.FMT_SUP_E: "",
- Tokenizer.FMT_SUB_B: "",
- Tokenizer.FMT_SUB_E: "",
- Tokenizer.FMT_DL_B: "",
- Tokenizer.FMT_DL_E: "",
- Tokenizer.FMT_ADL_B: "",
- Tokenizer.FMT_ADL_E: "",
- Tokenizer.FMT_STRIP: "",
+# Each opener tag, with the id of its corresponding closer and tag format
+HTML_OPENER: dict[int, tuple[int, str]] = {
+ Tokenizer.FMT_B_B: (Tokenizer.FMT_B_E, ""),
+ Tokenizer.FMT_I_B: (Tokenizer.FMT_I_E, ""),
+ Tokenizer.FMT_D_B: (Tokenizer.FMT_D_E, ""),
+ Tokenizer.FMT_U_B: (Tokenizer.FMT_U_E, ""),
+ Tokenizer.FMT_M_B: (Tokenizer.FMT_M_E, ""),
+ Tokenizer.FMT_SUP_B: (Tokenizer.FMT_SUP_E, ""),
+ Tokenizer.FMT_SUB_B: (Tokenizer.FMT_SUB_E, ""),
+ Tokenizer.FMT_DL_B: (Tokenizer.FMT_DL_E, ""),
+ Tokenizer.FMT_ADL_B: (Tokenizer.FMT_ADL_E, ""),
}
+# Each closer tag, with the id of its corresponding opener and tag format
+HTML_CLOSER: dict[int, tuple[int, str]] = {
+ Tokenizer.FMT_B_E: (Tokenizer.FMT_B_B, ""),
+ Tokenizer.FMT_I_E: (Tokenizer.FMT_I_B, ""),
+ Tokenizer.FMT_D_E: (Tokenizer.FMT_D_B, ""),
+ Tokenizer.FMT_U_E: (Tokenizer.FMT_U_B, ""),
+ Tokenizer.FMT_M_E: (Tokenizer.FMT_M_B, ""),
+ Tokenizer.FMT_SUP_E: (Tokenizer.FMT_SUP_B, ""),
+ Tokenizer.FMT_SUB_E: (Tokenizer.FMT_SUB_B, ""),
+ Tokenizer.FMT_DL_E: (Tokenizer.FMT_DL_B, ""),
+ Tokenizer.FMT_ADL_E: (Tokenizer.FMT_ADL_B, ""),
+}
+
+# Empty HTML tag record
+HTML_NONE = (0, "")
+
class ToHtml(Tokenizer):
"""Core: HTML Document Writer
@@ -447,19 +454,46 @@ class ToHtml(Tokenizer):
def _formatText(self, text: str, tFmt: T_Formats) -> str:
"""Apply formatting tags to text."""
temp = text
- for pos, fmt, data in reversed(tFmt):
- html = ""
- if fmt == self.FMT_FNOTE:
+
+ # Build a list of all html tags that need to be inserted in the text.
+ # This is done in the forward direction, and a tag is only opened if it
+ # isn't already open, and only closed if it has previously been opened.
+ tags: list[tuple[int, str]] = []
+ state = dict.fromkeys(HTML_OPENER, False)
+ for pos, fmt, data in tFmt:
+ if m := HTML_OPENER.get(fmt):
+ if not state.get(fmt, True):
+ tags.append((pos, m[1]))
+ state[fmt] = True
+ elif m := HTML_CLOSER.get(fmt):
+ if state.get(m[0], False):
+ tags.append((pos, m[1]))
+ state[m[0]] = False
+ elif fmt == self.FMT_FNOTE:
if data in self._footnotes:
index = len(self._usedNotes) + 1
self._usedNotes[data] = index
- html = f"{index}"
+ tags.append((pos, f"{index}"))
else:
- html = "ERR"
- else:
- html = HTML5_TAGS.get(fmt, "")
- temp = f"{temp[:pos]}{html}{temp[pos:]}"
+ tags.append((pos, "ERR"))
+
+ # Check all format types and close any tag that is still open. This
+ # ensures that unclosed tags don't spill over to the next paragraph.
+ end = len(text)
+ for opener, active in state.items():
+ if active:
+ closer = HTML_OPENER.get(opener, HTML_NONE)[0]
+ tags.append((end, HTML_CLOSER.get(closer, HTML_NONE)[1]))
+
+ # Insert all tags at their correct position, starting from the back.
+ # The reverse order ensures that the positions are not shifted while we
+ # insert tags.
+ for pos, tag in reversed(tags):
+ temp = f"{temp[:pos]}{tag}{temp[pos:]}"
+
+ # Replace all line breaks with proper HTML break tags
temp = temp.replace("\n", "
")
+
return stripEscape(temp)
def _formatSynopsis(self, text: str, synopsis: bool) -> str: