Files
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

448 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
novelWriter HTML Text Converter
=================================
File History:
Created: 2019-05-07 [0.0.1] ToHtml
This file is a part of novelWriter
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import json
import logging
from time import time
from typing import TYPE_CHECKING
from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwHtmlUnicode, nwStyles
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, stripEscape
from novelwriter.formats.tokenizer import COMMENT_BLOCKS, Tokenizer
from novelwriter.types import FONT_STYLE, FONT_WEIGHTS, QtHexRgb
if TYPE_CHECKING:
from pathlib import Path
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
# Each opener tag, with the id of its corresponding closer and tag format
HTML_OPENER: dict[int, tuple[int, str]] = {
TextFmt.B_B: (TextFmt.B_E, "<strong>"),
TextFmt.I_B: (TextFmt.I_E, "<em>"),
TextFmt.D_B: (TextFmt.D_E, "<del>"),
TextFmt.U_B: (TextFmt.U_E, "<span style='text-decoration: underline;'>"),
TextFmt.M_B: (TextFmt.M_E, "<mark>"),
TextFmt.SUP_B: (TextFmt.SUP_E, "<sup>"),
TextFmt.SUB_B: (TextFmt.SUB_E, "<sub>"),
TextFmt.COL_B: (TextFmt.COL_E, "<span style='color: {0}'>"),
TextFmt.ANM_B: (TextFmt.ANM_E, "<a name='{0}'>"),
TextFmt.ARF_B: (TextFmt.ARF_E, "<a href='{0}'>"),
TextFmt.HRF_B: (TextFmt.HRF_E, "<a href='{0}'>"),
}
# Each closer tag, with the id of its corresponding opener and tag format
HTML_CLOSER: dict[int, tuple[int, str]] = {
TextFmt.B_E: (TextFmt.B_B, "</strong>"),
TextFmt.I_E: (TextFmt.I_B, "</em>"),
TextFmt.D_E: (TextFmt.D_B, "</del>"),
TextFmt.U_E: (TextFmt.U_B, "</span>"),
TextFmt.M_E: (TextFmt.M_B, "</mark>"),
TextFmt.SUP_E: (TextFmt.SUP_B, "</sup>"),
TextFmt.SUB_E: (TextFmt.SUB_B, "</sub>"),
TextFmt.COL_E: (TextFmt.COL_B, "</span>"),
TextFmt.ANM_E: (TextFmt.ANM_B, "</a>"),
TextFmt.ARF_E: (TextFmt.ARF_B, "</a>"),
TextFmt.HRF_E: (TextFmt.HRF_B, "</a>"),
}
# Empty HTML tag record
HTML_NONE = (0, "")
class ToHtml(Tokenizer):
"""Core: HTML Document Writer.
Extend the Tokenizer class to writer HTML output. This class is
also used by the Document Viewer, and Manuscript Build Preview.
"""
def __init__(self, project: NWProject) -> None:
super().__init__(project)
self._trMap = {}
self._cssStyles = True
self._usedNotes: dict[str, int] = {}
self._usedFields: list[tuple[int, str]] = []
self.setReplaceUnicode(False)
##
# Setters
##
def setStyles(self, cssStyles: bool) -> None:
"""Enable or disable CSS styling. Some elements may still have
class tags.
"""
self._cssStyles = cssStyles
def setReplaceUnicode(self, doReplace: bool) -> None:
"""Set the translation map to either minimal or full unicode for
html entities replacement.
"""
# Control characters must always be replaced
# Angle brackets are replaced later as they are also used in
# formatting codes
self._trMap = str.maketrans({"&": "&amp;"})
if doReplace:
# Extend to all relevant Unicode characters
self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H))
##
# Class Methods
##
def getFullResultSize(self) -> int:
"""Return the size of the full HTML result."""
return sum(len(x) for x in self._pages)
def doPreProcessing(self) -> None:
"""Extend the auto-replace to also properly encode some unicode
characters into their respective HTML entities.
"""
super().doPreProcessing()
self._text = self._text.translate(self._trMap)
def doConvert(self) -> None:
"""Convert the list of text tokens into an HTML document."""
lines = []
for tType, tMeta, tText, tFmt, tStyle in self._blocks:
# Replace < and > with HTML entities
if tFmt:
# If we have formatting, we must recompute the locations
cText = []
i = 0
for c in tText:
if c == "<":
cText.append("&lt;")
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
i += 4
elif c == ">":
cText.append("&gt;")
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
i += 4
else:
cText.append(c)
i += 1
tText = "".join(cText)
else:
# If we don't have formatting, we can do a plain replace
tText = tText.replace("<", "&lt;").replace(">", "&gt;")
# Inline Styles
aStyle = []
if tStyle & BlockFmt.LEFT:
aStyle.append("text-align: left;")
elif tStyle & BlockFmt.RIGHT:
aStyle.append("text-align: right;")
elif tStyle & BlockFmt.CENTRE:
aStyle.append("text-align: center;")
elif tStyle & BlockFmt.JUSTIFY:
aStyle.append("text-align: justify;")
if tStyle & BlockFmt.PBB:
aStyle.append("page-break-before: always;")
if tStyle & BlockFmt.PBA:
aStyle.append("page-break-after: always;")
if tStyle & BlockFmt.Z_BTM:
aStyle.append("margin-bottom: 0;")
if tStyle & BlockFmt.Z_TOP:
aStyle.append("margin-top: 0;")
if tStyle & BlockFmt.IND_L:
aStyle.append(f"margin-left: {self._blockIndent:.2f}em;")
if tStyle & BlockFmt.IND_R:
aStyle.append(f"margin-right: {self._blockIndent:.2f}em;")
if tStyle & BlockFmt.IND_T:
aStyle.append(f"text-indent: {self._firstWidth:.2f}em;")
if aStyle:
stVals = " ".join(aStyle)
hStyle = f" style='{stVals}'"
else:
hStyle = ""
if self._linkHeadings and tMeta:
aNm = f"<a name='{tMeta}'></a>"
else:
aNm = ""
# Process Text Type
if tType == BlockTyp.TEXT:
lines.append(f"<p{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
elif tType in (BlockTyp.TITLE, BlockTyp.PART):
tHead = tText.replace("\n", "<br>")
lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")
elif tType == BlockTyp.HEAD1:
tHead = tText.replace("\n", "<br>")
lines.append(f"<h1{hStyle}>{aNm}{tHead}</h1>\n")
elif tType == BlockTyp.HEAD2:
tHead = tText.replace("\n", "<br>")
lines.append(f"<h2{hStyle}>{aNm}{tHead}</h2>\n")
elif tType == BlockTyp.HEAD3:
tHead = tText.replace("\n", "<br>")
lines.append(f"<h3{hStyle}>{aNm}{tHead}</h3>\n")
elif tType == BlockTyp.HEAD4:
tHead = tText.replace("\n", "<br>")
lines.append(f"<h4{hStyle}>{aNm}{tHead}</h4>\n")
elif tType == BlockTyp.SEP:
lines.append(f"<p class='sep'{hStyle}>{tText}</p>\n")
elif tType == BlockTyp.SKIP:
lines.append(f"<p{hStyle}>&nbsp;</p>\n")
elif tType in COMMENT_BLOCKS:
lines.append(f"<p class='comment'{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
elif tType == BlockTyp.KEYWORD:
tClass = f"meta meta-{tMeta}"
lines.append(f"<p class='{tClass}'{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
self._pages.append("".join(lines))
def closeDocument(self) -> None:
"""Run close document tasks."""
# Replace fields if there are stats available
if self._usedFields and self._counts:
pages = len(self._pages)
for doc, field in self._usedFields:
if doc >= 0 and doc < pages and (value := self._counts.get(field)) is not None:
self._pages[doc] = self._pages[doc].replace(
f"{{{{{field}}}}}", self._formatInt(value)
)
# Add footnotes
if self._usedNotes:
footnotes = self._localLookup("Footnotes")
lines = []
lines.append(f"<h3>{footnotes}</h3>\n")
lines.append("<ol>\n")
for key, index in self._usedNotes.items():
if content := self._footnotes.get(key):
text = self._formatText(*content)
lines.append(f"<li id='footnote_{index}'><p>{text}</p></li>\n")
lines.append("</ol>\n")
self._pages.append("".join(lines))
def saveDocument(self, path: Path) -> None:
"""Save the data to an HTML file."""
if path.suffix.lower() == ".json":
ts = time()
data = {
"meta": {
"projectName": self._project.data.name,
"novelAuthor": self._project.data.author,
"buildTime": int(ts),
"buildTimeStr": formatTimeStamp(ts),
},
"text": {
"css": self.getStyleSheet(),
"html": [t.replace("\t", "&#09;").rstrip().split("\n") for t in self._pages],
}
}
with open(path, mode="w", encoding="utf-8") as fObj:
json.dump(data, fObj, indent=2)
else:
html = []
html.append("<!DOCTYPE html>")
html.append("<html>")
html.append("<head>")
html.append(f"<title>{self._project.data.name:s}</title>")
html.append("<meta charset='utf-8'>")
if self._cssStyles:
html.append("<meta name='viewport' content='width=device-width, initial-scale=1'>")
html.append("<style>")
html.extend(self.getStyleSheet())
html.append("</style>")
html.append("</head>")
html.append("<body>")
html.append(("".join(self._pages)).replace("\t", "&#09;").rstrip())
html.append("</body>")
html.append("</html>\n")
with open(path, mode="w", encoding="utf-8") as fObj:
fObj.write("\n".join(html))
logger.info("Wrote file: %s", path)
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = "&nbsp;") -> None:
"""Replace tabs with spaces in the html."""
tabSpace = spaceChar*nSpaces
pages = [aLine.replace("\t", tabSpace) for aLine in self._pages]
self._pages = pages
def getStyleSheet(self) -> list[str]:
"""Generate a stylesheet for the current settings."""
if not self._cssStyles:
return []
tColor = self._theme.text.name(QtHexRgb)
hColor = self._theme.head.name(QtHexRgb) if self._colorHeads else tColor
lColor = self._theme.head.name(QtHexRgb)
mColor = self._theme.highlight.name(QtHexRgb)
mtH0 = self._marginTitle[0]
mbH0 = self._marginTitle[1]
mtH1 = self._marginHead1[0]
mbH1 = self._marginHead1[1]
mtH2 = self._marginHead2[0]
mbH2 = self._marginHead2[1]
mtH3 = self._marginHead3[0]
mbH3 = self._marginHead3[1]
mtH4 = self._marginHead4[0]
mbH4 = self._marginHead4[1]
mtTT = self._marginText[0]
mbTT = self._marginText[1]
mtSP = self._marginSep[0]
mbSP = self._marginSep[1]
fSz0 = nwStyles.H_SIZES[0]
fSz1 = nwStyles.H_SIZES[1]
fSz2 = nwStyles.H_SIZES[2]
fSz3 = nwStyles.H_SIZES[3]
fSz4 = nwStyles.H_SIZES[4]
font = self._textFont
fFam = font.family()
fSz = font.pointSize()
fW = FONT_WEIGHTS.get(font.weight(), 400)
fS = FONT_STYLE.get(font.style(), "normal")
lHeight = round(100 * self._lineHeight)
styles = []
styles.append(
f"body {{color: {tColor}; font-family: '{fFam}'; font-size: {fSz}pt; "
f"font-weight: {fW}; font-style: {fS};}}"
)
styles.append(
f"p {{text-align: {self._defaultAlign}; line-height: {lHeight}%; "
f"margin-top: {mtTT:.2f}em; margin-bottom: {mbTT:.2f}em;}}"
)
styles.append(f"a {{color: {lColor};}}")
styles.append(f"mark {{background: {mColor};}}")
styles.append(f"h1, h2, h3, h4 {{color: {hColor}; page-break-after: avoid;}}")
styles.append(
f"h1 {{font-size: {fSz1:.2f}em; "
f"margin-top: {mtH1:.2f}em; margin-bottom: {mbH1:.2f}em;}}"
)
styles.append(
f"h2 {{font-size: {fSz2:.2f}em; "
f"margin-top: {mtH2:.2f}em; margin-bottom: {mbH2:.2f}em;}}"
)
styles.append(
f"h3 {{font-size: {fSz3:.2f}em; "
f"margin-top: {mtH3:.2f}em; margin-bottom: {mbH3:.2f}em;}}"
)
styles.append(
f"h4 {{font-size: {fSz4:.2f}em; "
f"margin-top: {mtH4:.2f}em; margin-bottom: {mbH4:.2f}em;}}"
)
styles.append(
f".title {{font-size: {fSz0:.2f}em; "
f"margin-top: {mtH0:.2f}em; margin-bottom: {mbH0:.2f}em;}}"
)
styles.append(
f".sep {{text-align: center; margin-top: {mtSP:.2f}em; margin-bottom: {mbSP:.2f}em;}}"
)
return styles
##
# Internal Functions
##
def _formatText(self, text: str, tFmt: T_Formats) -> str:
"""Apply formatting tags to text."""
temp = text
# 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)
plain = not self._cssStyles
for pos, fmt, data in tFmt:
if plain and fmt in (TextFmt.COL_B, TextFmt.COL_E):
# We ignore colour tags if CSS is off
continue
if m := HTML_OPENER.get(fmt):
if not state.get(fmt, True):
if fmt == TextFmt.COL_B and (color := self._classes.get(data)):
tags.append((pos, m[1].format(color.name(QtHexRgb))))
elif fmt in (TextFmt.ANM_B, TextFmt.ARF_B, TextFmt.HRF_B):
tags.append((pos, m[1].format(data or "#")))
else:
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 == TextFmt.FNOTE:
if data in self._footnotes:
index = len(self._usedNotes) + 1
self._usedNotes[data] = index
tags.append((pos, f"<sup><a href='#footnote_{index}'>{index}</a></sup>"))
else:
tags.append((pos, "<sup>ERR</sup>"))
elif fmt == TextFmt.FIELD:
if field := data.partition(":")[2]:
self._usedFields.append((len(self._pages), field))
tags.append((pos, f"{{{{{field}}}}}"))
# 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", "<br>")
return stripEscape(temp)