""" novelWriter – HTML Text Converter ================================= File History: Created: 2019-05-07 [0.0.1] This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen 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 . """ from __future__ import annotations import json import logging from time import time from pathlib import Path from novelwriter import CONFIG from novelwriter.common import formatTimeStamp from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwHtmlUnicode from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape logger = logging.getLogger(__name__) 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. """ M_PREVIEW = 0 # Tweak output for the DocViewer M_EXPORT = 1 # Tweak output for saving to HTML or printing M_EBOOK = 2 # Tweak output for converting to epub def __init__(self, project: NWProject) -> None: super().__init__(project) self._genMode = self.M_EXPORT self._cssStyles = True self._fullHTML: list[str] = [] # Internals self._trMap = {} self.setReplaceUnicode(False) return ## # Properties ## @property def fullHTML(self) -> list[str]: return self._fullHTML ## # Setters ## def setPreview(self, doComments: bool, doSynopsis: bool) -> None: """If we're using this class to generate markdown preview, we need to make a few changes to formatting, which is managed by these flags. """ self._genMode = self.M_PREVIEW self._doKeywords = True self._doComments = doComments self._doSynopsis = doSynopsis return def setStyles(self, cssStyles: bool) -> None: """Enable or disable CSS styling. Some elements may still have class tags. """ self._cssStyles = cssStyles return 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)) return ## # Class Methods ## def getFullResultSize(self) -> int: """Return the size of the full HTML result.""" return sum(len(x) for x in self._fullHTML) 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) return def doConvert(self) -> None: """Convert the list of text tokens into an HTML document.""" if self._genMode == self.M_PREVIEW: htmlTags = { # HTML4 + CSS2 (for Qt) self.FMT_B_B: "", self.FMT_B_E: "", self.FMT_I_B: "", self.FMT_I_E: "", self.FMT_D_B: "", self.FMT_D_E: "", } else: htmlTags = { # HTML5 (for export) self.FMT_B_B: "", self.FMT_B_E: "", self.FMT_I_B: "", self.FMT_I_E: "", self.FMT_D_B: "", self.FMT_D_E: "", } if self._isNovel and self._genMode != self.M_PREVIEW: # For story files, we bump the titles one level up h1Cl = " class='title'" h1 = "h1" h2 = "h1" h3 = "h2" h4 = "h3" else: h1Cl = "" h1 = "h1" h2 = "h2" h3 = "h3" h4 = "h4" self._result = "" para = [] pStyle = None lines = [] for tType, tLine, tText, tFormat, tStyle in self._tokens: # Replace < and > with HTML entities if tFormat: # If we have formatting, we must recompute the locations cText = [] i = 0 for c in tText: if c == "<": cText.append("<") tFormat = [[a + 3 if a > i else a, b, c] for a, b, c in tFormat] i += 4 elif c == ">": cText.append(">") tFormat = [[a + 3 if a > i else a, b, c] for a, b, c in tFormat] 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(">", ">") # Styles aStyle = [] if tStyle is not None and self._cssStyles: if tStyle & self.A_LEFT: aStyle.append("text-align: left;") elif tStyle & self.A_RIGHT: aStyle.append("text-align: right;") elif tStyle & self.A_CENTRE: aStyle.append("text-align: center;") elif tStyle & self.A_JUSTIFY: aStyle.append("text-align: justify;") if tStyle & self.A_PBB: aStyle.append("page-break-before: always;") if tStyle & self.A_PBA: aStyle.append("page-break-after: always;") if tStyle & self.A_Z_BTMMRG: aStyle.append("margin-bottom: 0;") if tStyle & self.A_Z_TOPMRG: aStyle.append("margin-top: 0;") if tStyle & self.A_IND_L: aStyle.append(f"margin-left: {CONFIG.tabWidth:d}px;") if tStyle & self.A_IND_R: aStyle.append(f"margin-right: {CONFIG.tabWidth:d}px;") if len(aStyle) > 0: stVals = " ".join(aStyle) hStyle = f" style='{stVals}'" else: hStyle = "" if self._linkHeaders: aNm = f"" else: aNm = "" # Process Text Type if tType == self.T_EMPTY: if pStyle is None: pStyle = "" if len(para) > 1 and self._cssStyles: pClass = " class='break'" else: pClass = "" if len(para) > 0: tTemp = "
".join(para) lines.append(f"{tTemp.rstrip()}

\n") para = [] pStyle = None elif tType == self.T_TITLE: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"

{aNm}{tHead}

\n") elif tType == self.T_UNNUM: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"<{h2}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD1: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD2: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"<{h2}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD3: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"<{h3}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD4: tHead = tText.replace(nwHeadFmt.BR, "
") lines.append(f"<{h4}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_SEP: lines.append(f"

{tText}

\n") elif tType == self.T_SKIP: lines.append(f"

 

\n") elif tType == self.T_TEXT: tTemp = tText if pStyle is None: pStyle = hStyle for xPos, xLen, xFmt in reversed(tFormat): tTemp = tTemp[:xPos] + htmlTags[xFmt] + tTemp[xPos+xLen:] para.append(stripEscape(tTemp.rstrip())) elif tType == self.T_SYNOPSIS and self._doSynopsis: lines.append(self._formatSynopsis(tText)) elif tType == self.T_COMMENT and self._doComments: lines.append(self._formatComments(tText)) elif tType == self.T_KEYWORD and self._doKeywords: tTemp = f"{self._formatKeywords(tText)}

\n" lines.append(tTemp) self._result = "".join(lines) if self._genMode != self.M_PREVIEW: self._fullHTML.append(self._result) return def saveHtml5(self, path: str | Path) -> None: """Save the data to an HTML file.""" with open(path, mode="w", encoding="utf-8") as fObj: fObj.write(( "\n" "\n" "\n" "\n" "{title:s}\n" "\n" "\n" "\n" "
\n" "{body:s}\n" "
\n" "\n" "\n" ).format( title=self._project.data.name, style="\n".join(self.getStyleSheet()), body=("".join(self._fullHTML)).replace("\t", " ").rstrip(), )) logger.info("Wrote file: %s", path) return def saveHtmlJson(self, path: str | Path) -> None: """Save the data to a JSON file.""" timeStamp = time() data = { "meta": { "projectName": self._project.data.name, "novelTitle": self._project.data.title, "novelAuthor": self._project.data.author, "buildTime": int(timeStamp), "buildTimeStr": formatTimeStamp(timeStamp), }, "text": { "css": self.getStyleSheet(), "html": [t.replace("\t", " ").rstrip().split("\n") for t in self.fullHTML], } } with open(path, mode="w", encoding="utf-8") as fObj: json.dump(data, fObj, indent=2) logger.info("Wrote file: %s", path) return def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None: """Replace tabs with spaces in the html.""" htmlText = [] tabSpace = spaceChar*nSpaces for aLine in self._fullHTML: htmlText.append(aLine.replace("\t", tabSpace)) self._fullHTML = htmlText return def getStyleSheet(self) -> list[str]: """Generate a stylesheet for the current settings.""" styles = [] if not self._cssStyles: return styles mScale = self._lineHeight/1.15 textAlign = "justify" if self._doJustify else "left" styles.append("body {{font-family: '{0:s}'; font-size: {1:d}pt;}}".format( self._textFont, self._textSize )) styles.append(( "p {{" "text-align: {0}; line-height: {1:d}%; " "margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;" "}}" ).format( textAlign, round(100 * self._lineHeight), mScale * self._marginText[0], mScale * self._marginText[1], )) styles.append(( "h1 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale * self._marginHead1[0], mScale * self._marginHead1[1] )) styles.append(( "h2 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale * self._marginHead2[0], mScale * self._marginHead2[1] )) styles.append(( "h3 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale * self._marginHead3[0], mScale * self._marginHead3[1] )) styles.append(( "h4 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale * self._marginHead4[0], mScale * self._marginHead4[1] )) styles.append(( ".title {{" "font-size: 2.5em; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale * self._marginTitle[0], mScale * self._marginTitle[1] )) styles.append(( ".sep, .skip {{" "text-align: center; " "margin-top: {0:.2f}em; " "margin-bottom: {1:.2f}em;" "}}" ).format( mScale, mScale )) styles.append("a {color: rgb(66, 113, 174);}") styles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}") styles.append(".break {text-align: left;}") styles.append(".synopsis {font-style: italic;}") styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}") return styles ## # Internal Functions ## def _formatSynopsis(self, text: str) -> str: """Apply HTML formatting to synopsis.""" if self._genMode == self.M_PREVIEW: sSynop = self._trSynopsis return f"

{sSynop}: {text}

\n" else: sSynop = self._localLookup("Synopsis") return f"

{sSynop}: {text}

\n" def _formatComments(self, text: str) -> str: """Apply HTML formatting to comments.""" if self._genMode == self.M_PREVIEW: return f"

{text}

\n" else: sComm = self._localLookup("Comment") return f"

{sComm}: {text}

\n" def _formatKeywords(self, text: str) -> str: """Apply HTML formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) if not valid or not bits: return "" result = "" tags = [] if bits[0] in nwLabels.KEY_NAME: result += f"{nwLabels.KEY_NAME[bits[0]]}: " if len(bits) > 1: if bits[0] == nwKeyWords.TAG_KEY: result += f"{bits[1]}" else: if self._genMode == self.M_PREVIEW: for tTag in bits[1:]: tags.append(f"{tTag}") result += ", ".join(tags) else: for tTag in bits[1:]: tags.append(f"{tTag}") result += ", ".join(tags) return result # END Class ToHtml