""" novelWriter – HTML Text Converter ================================= Extends the Tokenizer class to generate HTML output File History: Created: 2019-05-07 [0.0.1] This file is a part of novelWriter Copyright 2018–2021, 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 . """ import logging from nw.core.tokenizer import Tokenizer from nw.constants import nwKeyWords, nwLabels, nwHtmlUnicode logger = logging.getLogger(__name__) class ToHtml(Tokenizer): 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, theProject): Tokenizer.__init__(self, theProject) self.genMode = self.M_EXPORT self.cssStyles = True self.fullHTML = [] # Internals self._trMap = {} self.setReplaceUnicode(False) return ## # Setters ## def setPreview(self, doComments, doSynopsis): """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): """Enable/disable CSS styling. Some elements may still have class tags. """ self.cssStyles = cssStyles return def setReplaceUnicode(self, doReplace): """Set the translation map to either minimal or full unicode to html entities replacement. """ # Control characters must always be replaced # This affects alignment and indenting code, so the Tokenizer # must take this into account when parsing for markup using # angle brackets. 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): """Return the size of the full HTML result. """ return sum([len(x) for x in self.fullHTML]) def doPreProcessing(self): """Extend the auto-replace to also properly encode some unicode characters into their respective HTML entities. """ Tokenizer.doPreProcessing(self) self.theText = self.theText.translate(self._trMap) return def doConvert(self): """Convert the list of text tokens into a HTML document saved to theResult. """ if self.genMode == self.M_PREVIEW: htmlTags = { # HTML4 + CSS2 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 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 novel files for export, we bump the titles one level # up as this is more useful for printing and word processor # imports. h1Cl = " class='title'" h1 = "h1" h2 = "h1" h3 = "h2" h4 = "h3" else: h1Cl = "" h1 = "h1" h2 = "h2" h3 = "h3" h4 = "h4" self.theResult = "" thisPar = [] parStyle = None tmpResult = [] for tType, tLine, tText, tFormat, tStyle in self.theTokens: # 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;") elif tStyle & self.A_PBB_AUT: aStyle.append("page-break-before: auto;") if tStyle & self.A_PBA: aStyle.append("page-break-after: always;") elif tStyle & self.A_PBA_AUT: aStyle.append("page-break-after: auto;") 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("margin-left: %dpx;" % self.mainConf.tabWidth) if tStyle & self.A_IND_R: aStyle.append("margin-right: %dpx;" % self.mainConf.tabWidth) if len(aStyle) > 0: hStyle = " style='%s'" % (" ".join(aStyle)) else: hStyle = "" if self.linkHeaders: aNm = "" % tLine else: aNm = "" # Process Text Type if tType == self.T_EMPTY: if parStyle is None: parStyle = "" if len(thisPar) > 1 and self.cssStyles: parClass = " class='break'" else: parClass = "" if len(thisPar) > 0: tTemp = "
".join(thisPar) tmpResult.append("%s

\n" % (parStyle, parClass, tTemp.rstrip())) thisPar = [] parStyle = None elif tType == self.T_TITLE: tHead = tText.replace(r"\\", "
") tmpResult.append("

%s%s

\n" % (hStyle, aNm, tHead)) elif tType == self.T_HEAD1: tHead = tText.replace(r"\\", "
") tmpResult.append("<%s%s%s>%s%s\n" % (h1, h1Cl, hStyle, aNm, tHead, h1)) elif tType == self.T_HEAD2: tHead = tText.replace(r"\\", "
") tmpResult.append("<%s%s>%s%s\n" % (h2, hStyle, aNm, tHead, h2)) elif tType == self.T_HEAD3: tHead = tText.replace(r"\\", "
") tmpResult.append("<%s%s>%s%s\n" % (h3, hStyle, aNm, tHead, h3)) elif tType == self.T_HEAD4: tHead = tText.replace(r"\\", "
") tmpResult.append("<%s%s>%s%s\n" % (h4, hStyle, aNm, tHead, h4)) elif tType == self.T_SEP: tmpResult.append("

%s

\n" % tText) elif tType == self.T_SKIP: tmpResult.append("

 

\n") elif tType == self.T_TEXT: tTemp = tText if parStyle is None: parStyle = hStyle for xPos, xLen, xFmt in reversed(tFormat): tTemp = tTemp[:xPos] + htmlTags[xFmt] + tTemp[xPos+xLen:] thisPar.append(tTemp.rstrip()) elif tType == self.T_SYNOPSIS and self.doSynopsis: tmpResult.append(self._formatSynopsis(tText)) elif tType == self.T_COMMENT and self.doComments: tmpResult.append(self._formatComments(tText)) elif tType == self.T_KEYWORD and self.doKeywords: tTemp = "%s

\n" % (hStyle, self._formatKeywords(tText)) tmpResult.append(tTemp) self.theResult = "".join(tmpResult) tmpResult = [] if self.genMode != self.M_PREVIEW: self.fullHTML.append(self.theResult) return def saveHTML5(self, savePath): """Save the data to an .html file. """ with open(savePath, mode="w", encoding="utf-8") as outFile: theStyle = self.getStyleSheet() theStyle.append("article {width: 800px; margin: 40px auto;}") bodyText = "".join(self.fullHTML) bodyText = bodyText.replace("\t", " ").rstrip() theHtml = ( "\n" "\n" "\n" "\n" "{projTitle:s}\n" "\n" "\n" "\n" "
\n" "{bodyText:s}\n" "
\n" "\n" "\n" ).format( projTitle=self.theProject.projName, htmlStyle="\n".join(theStyle), bodyText=bodyText, ) outFile.write(theHtml) return def replaceTabs(self, nSpaces=8, spaceChar=" "): """Replace tabs with spaces in the html. """ htmlText = [] eightSpace = spaceChar*nSpaces for aLine in self.fullHTML: htmlText.append(aLine.replace("\t", eightSpace)) self.fullHTML = htmlText return def getStyleSheet(self): """Generate a stylesheet appropriate for the current settings. """ theStyles = [] if not self.cssStyles: return theStyles mScale = self.lineHeight/1.15 textAlign = "justify" if self.doJustify else "left" theStyles.append("body {font-family: '%s'; font-size: %dpt;}" % ( self.textFont, self.textSize )) theStyles.append(( "p {" "text-align: %s; line-height: %d%%; " "margin-top: %.2fem; margin-bottom: %.2fem;" "}" ) % ( textAlign, round(100 * self.lineHeight), mScale * self.marginText[0], mScale * self.marginText[1], )) theStyles.append(( "h1 {" "color: rgb(66, 113, 174); " "page-break-after: avoid; " "margin-top: %.2fem; " "margin-bottom: %.2fem;" "}" ) % ( mScale * self.marginHead1[0], mScale * self.marginHead1[1] )) theStyles.append(( "h2 {" "color: rgb(66, 113, 174); " "page-break-after: avoid; " "margin-top: %.2fem; " "margin-bottom: %.2fem;" "}" ) % ( mScale * self.marginHead2[0], mScale * self.marginHead2[1] )) theStyles.append(( "h3 {" "color: rgb(50, 50, 50); " "page-break-after: avoid; " "margin-top: %.2fem; " "margin-bottom: %.2fem;" "}" ) % ( mScale * self.marginHead3[0], mScale * self.marginHead3[1] )) theStyles.append(( "h4 {" "color: rgb(50, 50, 50); " "page-break-after: avoid; " "margin-top: %.2fem; " "margin-bottom: %.2fem;" "}" ) % ( mScale * self.marginHead4[0], mScale * self.marginHead4[1] )) theStyles.append(( ".title {" "font-size: 2.5em; " "margin-top: %.2fem; " "margin-bottom: %.2fem;" "}" ) % ( mScale * self.marginTitle[0], mScale * self.marginTitle[1] )) theStyles.append(( ".sep, .skip {" "text-align: center; " "margin-top: %.2fem; " "margin-bottom: %.2fem;}" ) % ( mScale, mScale )) theStyles.append("a {color: rgb(66, 113, 174);}") theStyles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}") theStyles.append(".break {text-align: left;}") theStyles.append(".synopsis {font-style: italic;}") theStyles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}") return theStyles ## # Internal Functions ## def _formatSynopsis(self, tText): """Apply HTML formatting to synopsis. """ if self.genMode == self.M_PREVIEW: sSynop = self._trSynopsis return f"

{sSynop}: {tText}

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

{sSynop}: {tText}

\n" def _formatComments(self, tText): """Apply HTML formatting to comments. """ if self.genMode == self.M_PREVIEW: return f"

{tText}

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

{sComm}: {tText}

\n" def _formatKeywords(self, tText): """Apply HTML formatting to keywords. """ isValid, theBits, thePos = self.theParent.theIndex.scanThis("@"+tText) if not isValid or not theBits: return "" retText = "" refTags = [] if theBits[0] in nwLabels.KEY_NAME: retText += "%s: " % nwLabels.KEY_NAME[theBits[0]] if len(theBits) > 1: if theBits[0] == nwKeyWords.TAG_KEY: retText += "%s" % ( theBits[1], theBits[1] ) else: if self.genMode == self.M_PREVIEW: for tTag in theBits[1:]: refTags.append("%s" % ( theBits[0][1:], tTag, tTag )) retText += ", ".join(refTags) else: for tTag in theBits[1:]: refTags.append("%s" % ( tTag, tTag )) retText += ", ".join(refTags) return retText # END Class ToHtml