""" 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 . """ 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 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, ""), TextFmt.I_B: (TextFmt.I_E, ""), TextFmt.D_B: (TextFmt.D_E, ""), TextFmt.U_B: (TextFmt.U_E, ""), TextFmt.M_B: (TextFmt.M_E, ""), TextFmt.SUP_B: (TextFmt.SUP_E, ""), TextFmt.SUB_B: (TextFmt.SUB_E, ""), TextFmt.COL_B: (TextFmt.COL_E, ""), TextFmt.ANM_B: (TextFmt.ANM_E, ""), TextFmt.ARF_B: (TextFmt.ARF_E, ""), TextFmt.HRF_B: (TextFmt.HRF_E, ""), } # 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, ""), TextFmt.I_E: (TextFmt.I_B, ""), TextFmt.D_E: (TextFmt.D_B, ""), TextFmt.U_E: (TextFmt.U_B, ""), TextFmt.M_E: (TextFmt.M_B, ""), TextFmt.SUP_E: (TextFmt.SUP_B, ""), TextFmt.SUB_E: (TextFmt.SUB_B, ""), TextFmt.COL_E: (TextFmt.COL_B, ""), TextFmt.ANM_E: (TextFmt.ANM_B, ""), TextFmt.ARF_E: (TextFmt.ARF_B, ""), TextFmt.HRF_E: (TextFmt.HRF_B, ""), } # 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) return ## # Setters ## 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._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) return 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(">", ">") # Styles aStyle = [] if self._cssStyles: 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"" else: aNm = "" # Process Text Type if tType == BlockTyp.TEXT: lines.append(f"{self._formatText(tText, tFmt)}

\n") elif tType in (BlockTyp.TITLE, BlockTyp.PART): tHead = tText.replace("\n", "
") lines.append(f"

{aNm}{tHead}

\n") elif tType == BlockTyp.HEAD1: tHead = tText.replace("\n", "
") lines.append(f"{aNm}{tHead}\n") elif tType == BlockTyp.HEAD2: tHead = tText.replace("\n", "
") lines.append(f"{aNm}{tHead}\n") elif tType == BlockTyp.HEAD3: tHead = tText.replace("\n", "
") lines.append(f"{aNm}{tHead}\n") elif tType == BlockTyp.HEAD4: tHead = tText.replace("\n", "
") lines.append(f"{aNm}{tHead}\n") elif tType == BlockTyp.SEP: lines.append(f"

{tText}

\n") elif tType == BlockTyp.SKIP: lines.append(f" 

\n") elif tType == BlockTyp.COMMENT: lines.append(f"

{self._formatText(tText, tFmt)}

\n") elif tType == BlockTyp.KEYWORD: tClass = f"meta meta-{tMeta}" lines.append(f"

{self._formatText(tText, tFmt)}

\n") self._pages.append("".join(lines)) return 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"

{footnotes}

\n") lines.append("
    \n") for key, index in self._usedNotes.items(): if content := self._footnotes.get(key): text = self._formatText(*content) lines.append(f"
  1. {text}

  2. \n") lines.append("
\n") self._pages.append("".join(lines)) return 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: with open(path, mode="w", encoding="utf-8") as fObj: fObj.write(( "\n" "\n" "\n" "\n" "{title:s}\n" "\n" "\n" "\n" "{body:s}\n" "\n" "\n" ).format( title=self._project.data.name, style="\n".join(self.getStyleSheet()), body=("".join(self._pages)).replace("\t", " ").rstrip(), )) logger.info("Wrote file: %s", path) return 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 return 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) for pos, fmt, data in tFmt: 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"{index}")) else: tags.append((pos, "ERR")) 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", "
") return stripEscape(temp)