448 lines
17 KiB
Python
448 lines
17 KiB
Python
"""
|
||
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({"&": "&"})
|
||
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("<")
|
||
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
|
||
i += 4
|
||
elif c == ">":
|
||
cText.append(">")
|
||
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("<", "<").replace(">", ">")
|
||
|
||
# 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}> </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", "	").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", "	").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 = " ") -> 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)
|