From c537d0f3962acf90cb159f23bb8dc361d6f09243 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:37:00 +0200 Subject: [PATCH] Process comments in the Tokenizer --- novelwriter/assets/i18n/project_en_GB.json | 2 +- novelwriter/formats/shared.py | 40 +++---- novelwriter/formats/todocx.py | 72 +++++------- novelwriter/formats/tohtml.py | 22 +--- novelwriter/formats/tokenizer.py | 102 ++++++++++------- novelwriter/formats/tomarkdown.py | 13 +-- novelwriter/formats/toodt.py | 99 ++++++++--------- novelwriter/formats/toqdoc.py | 39 +++---- .../fmtToDocX_SaveDocument_document.xml | 81 ++++++++++++-- .../fmtToDocX_SaveDocument_styles.xml | 1 - .../reference/fmtToOdt_SaveFlat_document.fodt | 6 +- tests/reference/fmtToOdt_SaveFull_styles.xml | 2 +- ...uildDocBuild_OpenDocument_Lorem_Ipsum.fodt | 42 ++++--- tests/test_formats/test_fmt_todocx.py | 24 ++-- tests/test_formats/test_fmt_tohtml.py | 13 --- tests/test_formats/test_fmt_tokenizer.py | 103 +++++++++++++----- tests/test_formats/test_fmt_toodt.py | 65 +++++------ 17 files changed, 392 insertions(+), 334 deletions(-) diff --git a/novelwriter/assets/i18n/project_en_GB.json b/novelwriter/assets/i18n/project_en_GB.json index 39d52e6e..fe4be646 100644 --- a/novelwriter/assets/i18n/project_en_GB.json +++ b/novelwriter/assets/i18n/project_en_GB.json @@ -3,7 +3,7 @@ "Short Description": "Short Description", "Footnotes": "Footnotes", "Comment": "Comment", - "Notes": "Notes", + "Note": "Note", "Tag": "Tag", "Point of View": "Point of View", "Focus": "Focus", diff --git a/novelwriter/formats/shared.py b/novelwriter/formats/shared.py index 0c8fb701..70701e6f 100644 --- a/novelwriter/formats/shared.py +++ b/novelwriter/formats/shared.py @@ -84,12 +84,14 @@ class TextFmt(IntEnum): SUP_E = 12 # End superscript SUB_B = 13 # Begin subscript SUB_E = 14 # End subscript - DL_B = 15 # Begin dialogue - DL_E = 16 # End dialogue - ADL_B = 17 # Begin alt dialogue - ADL_E = 18 # End alt dialogue - FNOTE = 19 # Footnote marker - STRIP = 20 # Strip the format code + COL_B = 15 # Begin colour + COL_E = 16 # End colour + DL_B = 17 # Begin dialogue + DL_E = 18 # End dialogue + ADL_B = 19 # Begin alt dialogue + ADL_E = 20 # End alt dialogue + FNOTE = 21 # Footnote marker + STRIP = 22 # Strip the format code class BlockTyp(IntEnum): @@ -98,19 +100,19 @@ class BlockTyp(IntEnum): An enum indicating the type of a text block. """ - EMPTY = 1 # Empty line (new paragraph) - SYNOPSIS = 2 # Synopsis comment - SHORT = 3 # Short description comment - COMMENT = 4 # Comment line - KEYWORD = 5 # Command line - TITLE = 6 # Title - HEAD1 = 7 # Heading 1 - HEAD2 = 8 # Heading 2 - HEAD3 = 9 # Heading 3 - HEAD4 = 10 # Heading 4 - TEXT = 11 # Text line - SEP = 12 # Scene separator - SKIP = 13 # Paragraph break + EMPTY = 1 # Empty line (new paragraph) + TITLE = 2 # Title + HEAD1 = 3 # Heading 1 + HEAD2 = 4 # Heading 2 + HEAD3 = 5 # Heading 3 + HEAD4 = 6 # Heading 4 + TEXT = 7 # Text line + SEP = 8 # Scene separator + SKIP = 9 # Paragraph break + SUMMARY = 10 # Synopsis/short comment + NOTE = 11 # Note + COMMENT = 12 # Comment + KEYWORD = 13 # Tag/reference keywords class BlockFmt(Flag): diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py index e5b4c0a6..b4af3dac 100644 --- a/novelwriter/formats/todocx.py +++ b/novelwriter/formats/todocx.py @@ -95,8 +95,9 @@ X_UND = 0x008 # Underline format X_MRK = 0x010 # Marked format X_SUP = 0x020 # Superscript X_SUB = 0x040 # Subscript -X_DLG = 0x080 # Dialogue -X_DLA = 0x100 # Alt. Dialogue +X_COL = 0x080 # Coloured text +X_DLG = 0x100 # Dialogue +X_DLA = 0x200 # Alt. Dialogue # Formatting Masks M_BLD = ~X_BLD @@ -106,6 +107,7 @@ M_UND = ~X_UND M_MRK = ~X_MRK M_SUP = ~X_SUP M_SUB = ~X_SUB +M_COL = ~X_COL M_DLG = ~X_DLG M_DLA = ~X_DLA @@ -214,6 +216,7 @@ class ToDocX(Tokenizer): def initDocument(self) -> None: """Initialises the DocX document structure.""" + super().initDocument() self._fontFamily = self._textFont.family() self._fontSize = self._textFont.pointSizeF() self._generateStyles() @@ -289,17 +292,8 @@ class ToDocX(Tokenizer): elif tType == BlockTyp.SKIP: self._processFragments(par, S_NORM, "") - elif tType == BlockTyp.SYNOPSIS and self._doSynopsis: - tTemp, tFmt = self._formatSynopsis(tText, tFormat, True) - self._processFragments(par, S_META, tTemp, tFmt) - - elif tType == BlockTyp.SHORT and self._doSynopsis: - tTemp, tFmt = self._formatSynopsis(tText, tFormat, False) - self._processFragments(par, S_META, tTemp, tFmt) - - elif tType == BlockTyp.COMMENT and self._doComments: - tTemp, tFmt = self._formatComments(tText, tFormat) - self._processFragments(par, S_META, tTemp, tFmt) + elif tType in self.L_NOTES: + self._processFragments(par, S_META, tText, tFormat) elif tType == BlockTyp.KEYWORD and self._doKeywords: tTemp, tFmt = self._formatKeywords(tText) @@ -379,24 +373,6 @@ class ToDocX(Tokenizer): # Internal Functions ## - def _formatSynopsis(self, text: str, fmt: T_Formats, synopsis: bool) -> tuple[str, T_Formats]: - """Apply formatting to synopsis lines.""" - name = self._localLookup("Synopsis" if synopsis else "Short Description") - shift = len(name) + 2 - rTxt = f"{name}: {text}" - rFmt: T_Formats = [(0, TextFmt.B_B, ""), (len(name) + 1, TextFmt.B_E, "")] - rFmt.extend((p + shift, f, d) for p, f, d in fmt) - return rTxt, rFmt - - def _formatComments(self, text: str, fmt: T_Formats) -> tuple[str, T_Formats]: - """Apply formatting to comments.""" - name = self._localLookup("Comment") - shift = len(name) + 2 - rTxt = f"{name}: {text}" - rFmt: T_Formats = [(0, TextFmt.B_B, ""), (len(name) + 1, TextFmt.B_E, "")] - rFmt.extend((p + shift, f, d) for p, f, d in fmt) - return rTxt, rFmt - def _formatKeywords(self, text: str) -> tuple[str, T_Formats]: """Apply formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) @@ -421,6 +397,7 @@ class ToDocX(Tokenizer): xFmt = 0x00 xNode = None fStart = 0 + fClass = "" for fPos, fFmt, fData in tFmt or []: if xNode is not None: @@ -428,7 +405,7 @@ class ToDocX(Tokenizer): xNode = None if temp := text[fStart:fPos]: - par.addContent(self._textRunToXml(temp, xFmt)) + par.addContent(self._textRunToXml(temp, xFmt, fClass)) if fFmt == TextFmt.B_B: xFmt |= X_BLD @@ -458,6 +435,12 @@ class ToDocX(Tokenizer): xFmt |= X_SUB elif fFmt == TextFmt.SUB_E: xFmt &= M_SUB + elif fFmt == TextFmt.COL_B: + xFmt |= X_COL + fClass = fData + elif fFmt == TextFmt.COL_E: + xFmt &= M_COL + fClass = "" elif fFmt == TextFmt.DL_B: xFmt |= X_DLG elif fFmt == TextFmt.DL_E: @@ -478,33 +461,35 @@ class ToDocX(Tokenizer): par.addContent(xNode) if temp := text[fStart:]: - par.addContent(self._textRunToXml(temp, xFmt)) + par.addContent(self._textRunToXml(temp, xFmt, fClass)) return - def _textRunToXml(self, text: str, fmt: int) -> ET.Element: + def _textRunToXml(self, text: str, fmt: int, fClass: str = "") -> ET.Element: """Encode the text run into XML.""" run = ET.Element(_wTag("r")) rPr = xmlSubElem(run, _wTag("rPr")) - if fmt & X_BLD == X_BLD: + if fmt & X_BLD: xmlSubElem(rPr, _wTag("b")) - if fmt & X_ITA == X_ITA: + if fmt & X_ITA: xmlSubElem(rPr, _wTag("i")) - if fmt & X_UND == X_UND: + if fmt & X_UND: xmlSubElem(rPr, _wTag("u"), attrib={_wTag("val"): "single"}) - if fmt & X_MRK == X_MRK: + if fmt & X_MRK: xmlSubElem(rPr, _wTag("shd"), attrib={ _wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear", }) - if fmt & X_DEL == X_DEL: + if fmt & X_DEL: xmlSubElem(rPr, _wTag("strike")) - if fmt & X_SUP == X_SUP: + if fmt & X_SUP: xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"}) - if fmt & X_SUB == X_SUB: + if fmt & X_SUB: xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"}) - if fmt & X_DLG == X_DLG: + if fmt & X_COL and (color := self._classes.get(fClass)): + xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): _docXCol(color)}) + if fmt & X_DLG: xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M}) - if fmt & X_DLA == X_DLA: + if fmt & X_DLA: xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A}) for segment in RX_TEXT.split(text): @@ -659,7 +644,6 @@ class ToDocX(Tokenizer): before=fSz * self._marginMeta[0], after=fSz * self._marginMeta[1], line=fSz * self._lineHeight, - color=COL_META_TXT, )) # Header diff --git a/novelwriter/formats/tohtml.py b/novelwriter/formats/tohtml.py index b00a95b8..4efbd1f5 100644 --- a/novelwriter/formats/tohtml.py +++ b/novelwriter/formats/tohtml.py @@ -251,14 +251,11 @@ class ToHtml(Tokenizer): elif tType == BlockTyp.SKIP: lines.append(f"
\n") - elif tType == BlockTyp.SYNOPSIS and self._doSynopsis: - lines.append(self._formatSynopsis(self._formatText(tText, tFormat), True)) - - elif tType == BlockTyp.SHORT and self._doSynopsis: - lines.append(self._formatSynopsis(self._formatText(tText, tFormat), False)) + elif tType == BlockTyp.SUMMARY: + lines.append(f"
{self._formatText(tText, tFormat)}
\n") elif tType == BlockTyp.COMMENT and self._doComments: - lines.append(self._formatComments(self._formatText(tText, tFormat))) + lines.append(f"{self._formatText(tText, tFormat)}
\n") elif tType == BlockTyp.KEYWORD and self._doKeywords: tag, text = self._formatKeywords(tText) @@ -499,19 +496,6 @@ class ToHtml(Tokenizer): return stripEscape(temp) - def _formatSynopsis(self, text: str, synopsis: bool) -> str: - """Apply HTML formatting to synopsis.""" - if synopsis: - sSynop = self._localLookup("Synopsis") - else: - sSynop = self._localLookup("Short Description") - return f"{sSynop}: {text}
\n" - - def _formatComments(self, text: str) -> str: - """Apply HTML formatting to comments.""" - sComm = self._localLookup("Comment") - return f"{sComm}: {text}
\n" - def _formatKeywords(self, text: str) -> tuple[str, str]: """Apply HTML formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py index 74f271b5..c50f1f16 100644 --- a/novelwriter/formats/tokenizer.py +++ b/novelwriter/formats/tokenizer.py @@ -32,9 +32,10 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path from time import time +from typing import NamedTuple from PyQt5.QtCore import QCoreApplication -from PyQt5.QtGui import QFont +from PyQt5.QtGui import QColor, QFont from novelwriter import CONFIG from novelwriter.common import checkInt, formatTimeStamp, numberToRoman @@ -52,6 +53,26 @@ from novelwriter.text.patterns import REGEX_PATTERNS logger = logging.getLogger(__name__) +class ComStyle(NamedTuple): + + label: str = "" + labelClass: str = "" + textClass: str = "" + blockType: BlockTyp = BlockTyp.TEXT + + +COMMENT_STYLE = { + nwComment.PLAIN: ComStyle("Comment", "comment", "comment", BlockTyp.COMMENT), + nwComment.IGNORE: ComStyle(), + nwComment.SYNOPSIS: ComStyle("Synopsis", "modifier", "synopsis", BlockTyp.SUMMARY), + nwComment.SHORT: ComStyle("Short Description", "modifier", "synopsis", BlockTyp.SUMMARY), + nwComment.NOTE: ComStyle("Note", "modifier", "note", BlockTyp.NOTE), + nwComment.FOOTNOTE: ComStyle("", "modifier", "note"), + nwComment.COMMENT: ComStyle(), + nwComment.STORY: ComStyle("", "modifier", "note", BlockTyp.NOTE), +} + + class Tokenizer(ABC): """Core: Text Tokenizer Abstract Base Class @@ -72,7 +93,7 @@ class Tokenizer(ABC): BlockTyp.TITLE, BlockTyp.HEAD1, BlockTyp.HEAD2, BlockTyp.HEAD2, BlockTyp.HEAD3, BlockTyp.HEAD4, BlockTyp.SEP, BlockTyp.SKIP, ] - L_SUMMARY = [BlockTyp.SYNOPSIS, BlockTyp.SHORT] + L_NOTES = [BlockTyp.SUMMARY, BlockTyp.NOTE, BlockTyp.COMMENT] def __init__(self, project: NWProject) -> None: @@ -114,6 +135,7 @@ class Tokenizer(ABC): # Other Setting self._theme = TextDocumentTheme() + self._classes: dict[str, QColor] = {} # Margins self._marginTitle = nwStyles.T_MARGIN["H0"] @@ -433,6 +455,13 @@ class Tokenizer(ABC): def saveDocument(self, path: Path) -> None: raise NotImplementedError + def initDocument(self) -> None: + """Initialise data after settings.""" + self._classes["modifier"] = self._theme.modifier + self._classes["synopsis"] = self._theme.note + self._classes["comment"] = self._theme.comment + return + def addRootHeading(self, tHandle: str) -> None: """Add a heading at the start of a new root folder.""" self._text = "" @@ -569,36 +598,29 @@ class Tokenizer(ABC): if aLine.startswith("%~"): continue + cStyle, cKey, cText, _, _ = processComment(aLine) + if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT) and not self._doSynopsis: + continue + if cStyle == nwComment.PLAIN and not self._doComments: + continue + if self._doJustify and not sAlign & self.M_ALIGNED: sAlign |= BlockFmt.JUSTIFY - cStyle, cKey, cText, _, _ = processComment(aLine) - if cStyle == nwComment.SYNOPSIS: - tLine, tFmt = self._extractFormats(cText) - blocks.append(( - BlockTyp.SYNOPSIS, nHead, tLine, tFmt, sAlign - )) - if self._doSynopsis and self._keepRaw: - tmpMarkdown.append(f"{aLine}\n") - elif cStyle == nwComment.SHORT: - tLine, tFmt = self._extractFormats(cText) - blocks.append(( - BlockTyp.SHORT, nHead, tLine, tFmt, sAlign - )) - if self._doSynopsis and self._keepRaw: + if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN): + bStyle = COMMENT_STYLE[cStyle] + tLine, tFmt = self._formatNote(bStyle, cKey, cText) + blocks.append((bStyle.blockType, nHead, tLine, tFmt, sAlign)) + if self._keepRaw: tmpMarkdown.append(f"{aLine}\n") + elif cStyle == nwComment.FOOTNOTE: tLine, tFmt = self._extractFormats(cText, skip=TextFmt.FNOTE) self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt) if self._keepRaw: tmpMarkdown.append(f"{aLine}\n") else: - tLine, tFmt = self._extractFormats(cText) - blocks.append(( - BlockTyp.COMMENT, nHead, tLine, tFmt, sAlign - )) - if self._doComments and self._keepRaw: - tmpMarkdown.append(f"{aLine}\n") + continue elif aLine.startswith("@"): # Keywords @@ -992,25 +1014,10 @@ class Tokenizer(ABC): allChars += nChars allWordChars += nWChars - elif tType == BlockTyp.SYNOPSIS and self._doSynopsis: - text = "{0}: {1}".format(self._localLookup("Synopsis"), tText) - words = text.split() + elif tType in self.L_NOTES: + words = tText.split() allWords += len(words) - allChars += len(text) - allWordChars += len("".join(words)) - - elif tType == BlockTyp.SHORT and self._doSynopsis: - text = "{0}: {1}".format(self._localLookup("Short Description"), tText) - words = text.split() - allWords += len(words) - allChars += len(text) - allWordChars += len("".join(words)) - - elif tType == BlockTyp.COMMENT and self._doComments: - text = "{0}: {1}".format(self._localLookup("Comment"), tText) - words = text.split() - allWords += len(words) - allChars += len(text) + allChars += len(tText) allWordChars += len("".join(words)) elif tType == BlockTyp.KEYWORD and self._doKeywords: @@ -1071,6 +1078,21 @@ class Tokenizer(ABC): # Internal Functions ## + def _formatNote(self, style: ComStyle, key: str, text: str) -> tuple[str, T_Formats]: + """Apply formatting to comments and notes.""" + tTxt, tFmt = self._extractFormats(text) + tFmt.insert(0, (0, TextFmt.COL_B, style.textClass)) + tFmt.append((len(tTxt), TextFmt.COL_E, "")) + if label := (self._localLookup(style.label) + (f" ({key})" if key else "")).strip(): + shift = len(label) + 2 + tTxt = f"{label}: {tTxt}" + rFmt = [(0, TextFmt.B_B, ""), (shift - 1, TextFmt.B_E, "")] + if style.labelClass: + rFmt.insert(1, (0, TextFmt.COL_B, style.labelClass)) + rFmt.append((shift - 1, TextFmt.COL_E, "")) + rFmt.extend((p + shift, f, d) for p, f, d in tFmt) + return tTxt, rFmt + def _extractFormats( self, text: str, skip: int = 0, hDialog: bool = False ) -> tuple[str, T_Formats]: diff --git a/novelwriter/formats/tomarkdown.py b/novelwriter/formats/tomarkdown.py index 2f9ccc93..2d44ea77 100644 --- a/novelwriter/formats/tomarkdown.py +++ b/novelwriter/formats/tomarkdown.py @@ -150,17 +150,8 @@ class ToMarkdown(Tokenizer): elif tType == BlockTyp.SKIP: lines.append(f"{cSkip}\n\n") - elif tType == BlockTyp.SYNOPSIS and self._doSynopsis: - label = self._localLookup("Synopsis") - lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") - - elif tType == BlockTyp.SHORT and self._doSynopsis: - label = self._localLookup("Short Description") - lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") - - elif tType == BlockTyp.COMMENT and self._doComments: - label = self._localLookup("Comment") - lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") + elif tType in self.L_NOTES: + lines.append(f"{self._formatText(tText, tFormat, mTags)}\n\n") elif tType == BlockTyp.KEYWORD and self._doKeywords: lines.append(self._formatKeywords(tText, tStyle)) diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py index 9f680814..325f929c 100644 --- a/novelwriter/formats/toodt.py +++ b/novelwriter/formats/toodt.py @@ -90,8 +90,9 @@ X_UND = 0x008 # Underline format X_MRK = 0x010 # Marked format X_SUP = 0x020 # Superscript X_SUB = 0x040 # Subscript -X_DLG = 0x080 # Dialogue -X_DLA = 0x100 # Alt. Dialogue +X_COL = 0x080 # Coloured text +X_DLG = 0x100 # Dialogue +X_DLA = 0x200 # Alt. Dialogue # Formatting Masks M_BLD = ~X_BLD @@ -101,6 +102,7 @@ M_UND = ~X_UND M_MRK = ~X_MRK M_SUP = ~X_SUP M_SUB = ~X_SUB +M_COL = ~X_COL M_DLG = ~X_DLG M_DLA = ~X_DLA @@ -152,7 +154,7 @@ class ToOdt(Tokenizer): self._mainPara: dict[str, ODTParagraphStyle] = {} # User-accessible paragraph styles self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles - self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text styles + self._autoText: dict[str, ODTTextStyle] = {} # Auto-generated text styles # Footnotes self._nNote = 0 @@ -264,6 +266,8 @@ class ToOdt(Tokenizer): def initDocument(self) -> None: """Initialises a new open document XML tree.""" + super().initDocument() + # Initialise Variables # ==================== @@ -480,17 +484,8 @@ class ToOdt(Tokenizer): elif tType == BlockTyp.SKIP: self._addTextPar(xText, S_TEXT, oStyle, "") - elif tType == BlockTyp.SYNOPSIS and self._doSynopsis: - tTemp, tFmt = self._formatSynopsis(tText, tFormat, True) - self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt) - - elif tType == BlockTyp.SHORT and self._doSynopsis: - tTemp, tFmt = self._formatSynopsis(tText, tFormat, False) - self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt) - - elif tType == BlockTyp.COMMENT and self._doComments: - tTemp, tFmt = self._formatComments(tText, tFormat) - self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt) + elif tType in self.L_NOTES: + self._addTextPar(xText, S_META, oStyle, tText, tFmt=tFormat) elif tType == BlockTyp.KEYWORD and self._doKeywords: tTemp, tFmt = self._formatKeywords(tText) @@ -562,24 +557,6 @@ class ToOdt(Tokenizer): # Internal Functions ## - def _formatSynopsis(self, text: str, fmt: T_Formats, synopsis: bool) -> tuple[str, T_Formats]: - """Apply formatting to synopsis lines.""" - name = self._localLookup("Synopsis" if synopsis else "Short Description") - shift = len(name) + 2 - rTxt = f"{name}: {text}" - rFmt: T_Formats = [(0, TextFmt.B_B, ""), (len(name) + 1, TextFmt.B_E, "")] - rFmt.extend((p + shift, f, d) for p, f, d in fmt) - return rTxt, rFmt - - def _formatComments(self, text: str, fmt: T_Formats) -> tuple[str, T_Formats]: - """Apply formatting to comments.""" - name = self._localLookup("Comment") - shift = len(name) + 2 - rTxt = f"{name}: {text}" - rFmt: T_Formats = [(0, TextFmt.B_B, ""), (len(name) + 1, TextFmt.B_E, "")] - rFmt.extend((p + shift, f, d) for p, f, d in fmt) - return rTxt, rFmt - def _formatKeywords(self, text: str) -> tuple[str, T_Formats]: """Apply formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) @@ -630,6 +607,7 @@ class ToOdt(Tokenizer): tFrag = "" fLast = 0 xNode = None + fClass = "" for fPos, fFmt, fData in tFmt or []: # Add any extra nodes @@ -642,7 +620,7 @@ class ToOdt(Tokenizer): if xFmt == 0x00: parProc.appendText(tFrag) else: - parProc.appendSpan(tFrag, self._textStyle(xFmt)) + parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) # Calculate the change of format if fFmt == TextFmt.B_B: @@ -673,6 +651,12 @@ class ToOdt(Tokenizer): xFmt |= X_SUB elif fFmt == TextFmt.SUB_E: xFmt &= M_SUB + elif fFmt == TextFmt.COL_B: + xFmt |= X_COL + fClass = fData + elif fFmt == TextFmt.COL_E: + xFmt &= M_COL + fClass = "" elif fFmt == TextFmt.DL_B: xFmt |= X_DLG elif fFmt == TextFmt.DL_E: @@ -697,7 +681,7 @@ class ToOdt(Tokenizer): if xFmt == 0x00: parProc.appendText(tFrag) else: - parProc.appendSpan(tFrag, self._textStyle(xFmt)) + parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) if pErr > 0: self._errData.append("Unknown format tag encountered") @@ -731,10 +715,14 @@ class ToOdt(Tokenizer): return modStyle.name - def _textStyle(self, hFmt: int) -> str: + def _textStyle(self, hFmt: int, fClass: str = "") -> str: """Return a text style for a given style code.""" - if hFmt in self._autoText: - return self._autoText[hFmt].name + tKey = str(hFmt) + if fClass and (color := self._classes.get(fClass)): + tKey = f"{tKey}:{fClass}" + + if tKey in self._autoText: + return self._autoText[tKey].name style = ODTTextStyle(f"T{len(self._autoText)+1:d}") if hFmt & X_BLD: @@ -747,18 +735,20 @@ class ToOdt(Tokenizer): if hFmt & X_UND: style.setUnderlineStyle("solid") style.setUnderlineWidth("auto") - style.setUnderlineColour("font-color") + style.setUnderlineColor("font-color") if hFmt & X_MRK: - style.setBackgroundColour(self._markText) + style.setBackgroundColor(self._markText) if hFmt & X_SUP: style.setTextPosition("super") if hFmt & X_SUB: style.setTextPosition("sub") - if hFmt & X_DLG: - style.setColour(self._colDialogM) - if hFmt & X_DLA: - style.setColour(self._colDialogA) - self._autoText[hFmt] = style + if hFmt & X_COL and color: + style.setColor(color) + # if hFmt & X_DLG: + # style.setColour(self._colDialogM) + # if hFmt & X_DLA: + # style.setColour(self._colDialogA) + self._autoText[tKey] = style return style.name @@ -923,7 +913,6 @@ class ToOdt(Tokenizer): style.setFontFamily(self._fontFamily) style.setFontSize(self._fSizeText) style.setFontWeight(self._fontWeight) - style.setColour(self._theme.note) style.packXML(self._xStyl) self._mainPara[style.name] = style @@ -973,7 +962,7 @@ class ToOdt(Tokenizer): style.setFontFamily(self._fontFamily) style.setFontSize(self._fSizeHead1) style.setFontWeight(self._headWeight) - style.setColour(hColor) + style.setColor(hColor) style.packXML(self._xStyl) self._mainPara[style.name] = style @@ -990,7 +979,7 @@ class ToOdt(Tokenizer): style.setFontFamily(self._fontFamily) style.setFontSize(self._fSizeHead2) style.setFontWeight(self._headWeight) - style.setColour(hColor) + style.setColor(hColor) style.packXML(self._xStyl) self._mainPara[style.name] = style @@ -1007,7 +996,7 @@ class ToOdt(Tokenizer): style.setFontFamily(self._fontFamily) style.setFontSize(self._fSizeHead3) style.setFontWeight(self._headWeight) - style.setColour(hColor) + style.setColor(hColor) style.packXML(self._xStyl) self._mainPara[style.name] = style @@ -1024,7 +1013,7 @@ class ToOdt(Tokenizer): style.setFontFamily(self._fontFamily) style.setFontSize(self._fSizeHead4) style.setFontWeight(self._headWeight) - style.setColour(hColor) + style.setColor(hColor) style.packXML(self._xStyl) self._mainPara[style.name] = style @@ -1285,7 +1274,7 @@ class ODTParagraphStyle: self._tAttr["font-weight"][1] = None return - def setColour(self, value: QColor | None) -> None: + def setColor(self, value: QColor | None) -> None: """Set text colour.""" if isinstance(value, QColor): self._tAttr["color"][1] = value.name(QColor.NameFormat.HexRgb) @@ -1393,15 +1382,15 @@ class ODTTextStyle: self._tAttr["font-style"][1] = None return - def setColour(self, value: str | None) -> None: + def setColor(self, value: QColor | None) -> None: """Set text colour.""" - if value and len(value) == 7 and value[0] == "#": - self._tAttr["color"][1] = value + if isinstance(value, QColor): + self._tAttr["color"][1] = value.name(QColor.NameFormat.HexRgb) else: self._tAttr["color"][1] = None return - def setBackgroundColour(self, value: str | None) -> None: + def setBackgroundColor(self, value: str | None) -> None: """Set text background colour.""" if value and len(value) == 7 and value[0] == "#": self._tAttr["background-color"][1] = value @@ -1449,7 +1438,7 @@ class ODTTextStyle: self._tAttr["text-underline-width"][1] = None return - def setUnderlineColour(self, value: str | None) -> None: + def setUnderlineColor(self, value: str | None) -> None: """Set text underline colour.""" if value in self.VALID_LCOL: self._tAttr["text-underline-color"][1] = value diff --git a/novelwriter/formats/toqdoc.py b/novelwriter/formats/toqdoc.py index 1a48a562..ba17eeb2 100644 --- a/novelwriter/formats/toqdoc.py +++ b/novelwriter/formats/toqdoc.py @@ -108,6 +108,8 @@ class ToQTextDocument(Tokenizer): def initDocument(self) -> None: """Initialise all computed values of the document.""" + super().initDocument() + self._document.setUndoRedoEnabled(False) self._document.blockSignals(True) self._document.clear() @@ -162,23 +164,9 @@ class ToQTextDocument(Tokenizer): self._cText.setBackground(QtTransparent) self._cText.setForeground(self._theme.text) - self._cComment = QTextCharFormat(self._cText) - self._cComment.setForeground(self._theme.comment) - - self._cCommentMod = QTextCharFormat(self._cText) - self._cCommentMod.setForeground(self._theme.comment) - self._cCommentMod.setFontWeight(self._bold) - - self._cNote = QTextCharFormat(self._cText) - self._cNote.setForeground(self._theme.note) - self._cCode = QTextCharFormat(self._cText) self._cCode.setForeground(self._theme.code) - self._cModifier = QTextCharFormat(self._cText) - self._cModifier.setForeground(self._theme.modifier) - self._cModifier.setFontWeight(self._bold) - self._cKeyword = QTextCharFormat(self._cText) self._cKeyword.setForeground(self._theme.keyword) @@ -252,19 +240,9 @@ class ToQTextDocument(Tokenizer): newBlock(cursor, bFmt) cursor.insertText(nwUnicode.U_NBSP, self._cText) - elif tType in self.L_SUMMARY and self._doSynopsis: + elif tType in self.L_NOTES: newBlock(cursor, bFmt) - modifier = self._localLookup( - "Short Description" if tType == BlockTyp.SHORT else "Synopsis" - ) - cursor.insertText(f"{modifier}: ", self._cModifier) - self._insertFragments(tText, tFormat, cursor, self._cNote) - - elif tType == BlockTyp.COMMENT and self._doComments: - newBlock(cursor, bFmt) - modifier = self._localLookup("Comment") - cursor.insertText(f"{modifier}: ", self._cCommentMod) - self._insertFragments(tText, tFormat, cursor, self._cComment) + self._insertFragments(tText, tFormat, cursor, self._cText) elif tType == BlockTyp.KEYWORD and self._doKeywords: newBlock(cursor, bFmt) @@ -359,6 +337,15 @@ class ToQTextDocument(Tokenizer): cFmt.setVerticalAlignment(QtVAlignSub) elif fmt == TextFmt.SUB_E: cFmt.setVerticalAlignment(QtVAlignNormal) + elif fmt == TextFmt.SUB_B: + cFmt.setVerticalAlignment(QtVAlignSub) + elif fmt == TextFmt.SUB_E: + cFmt.setVerticalAlignment(QtVAlignNormal) + elif fmt == TextFmt.COL_B: + if color := self._classes.get(data): + cFmt.setForeground(color) + elif fmt == TextFmt.COL_E: + cFmt.setForeground(self._theme.text) elif fmt == TextFmt.DL_B: cFmt.setForeground(self._theme.dialog) elif fmt == TextFmt.DL_E: diff --git a/tests/reference/fmtToDocX_SaveDocument_document.xml b/tests/reference/fmtToDocX_SaveDocument_document.xml index 91dabb46..0ec28de5 100644 --- a/tests/reference/fmtToDocX_SaveDocument_document.xml +++ b/tests/reference/fmtToDocX_SaveDocument_document.xml @@ -53,12 +53,19 @@Synopsis: synopsis text
\n" - ) - assert html._formatSynopsis("short text", False) == ( - "Short Description: short text
\n" - ) - assert html._formatComments("comment text") == ( - "Comment: comment text
\n" - ) - assert html._formatKeywords("") == ("", "") assert html._formatKeywords("tag: Jane") == ( "tag", "Tag: Jane" diff --git a/tests/test_formats/test_fmt_tokenizer.py b/tests/test_formats/test_fmt_tokenizer.py index 10809b67..2ac1e672 100644 --- a/tests/test_formats/test_fmt_tokenizer.py +++ b/tests/test_formats/test_fmt_tokenizer.py @@ -29,8 +29,9 @@ from PyQt5.QtGui import QFont from novelwriter import CONFIG from novelwriter.constants import nwHeadFmt, nwStyles from novelwriter.core.project import NWProject +from novelwriter.enum import nwComment from novelwriter.formats.shared import BlockFmt, BlockTyp, TextFmt, stripEscape -from novelwriter.formats.tokenizer import HeadingFormatter, Tokenizer +from novelwriter.formats.tokenizer import COMMENT_STYLE, HeadingFormatter, Tokenizer from novelwriter.formats.tomarkdown import ToMarkdown from novelwriter.formats.toraw import ToRaw @@ -700,52 +701,64 @@ def testFmtToken_MetaFormat(mockGUI): project = NWProject() tokens = ToRaw(project) - # Comment - tokens._text = "% A comment\n" - tokens.tokenizeText() - assert tokens._blocks == [ - (BlockTyp.COMMENT, 0, "A comment", [], BlockFmt.NONE), - ] - assert tokens.allMarkdown[-1] == "\n" - - tokens.setComments(True) - tokens.tokenizeText() - assert tokens.allMarkdown[-1] == "% A comment\n\n" - # Ignore Text tokens._text = "%~ Some text\n" tokens.tokenizeText() assert tokens._blocks == [] assert tokens.allMarkdown[-1] == "\n" + # Comment + tokens.setComments(False) + tokens._text = "% A comment\n" + tokens.tokenizeText() + assert tokens._blocks == [] + assert tokens.allMarkdown[-1] == "\n" + + tokens.setComments(True) + tokens._text = "% A comment\n" + tokens.tokenizeText() + assert tokens._blocks == [( + BlockTyp.COMMENT, 0, "Comment: A comment", [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "comment"), (8, TextFmt.B_E, ""), + (8, TextFmt.COL_E, ""), (9, TextFmt.COL_B, "comment"), (18, TextFmt.COL_E, ""), + ], BlockFmt.NONE + )] + assert tokens.allMarkdown[-1] == "% A comment\n\n" + # Synopsis + tokens.setSynopsis(False) tokens._text = "%synopsis: The synopsis\n" tokens.tokenizeText() - assert tokens._blocks == [ - (BlockTyp.SYNOPSIS, 0, "The synopsis", [], BlockFmt.NONE), - ] - tokens._text = "% synopsis: The synopsis\n" - tokens.tokenizeText() - assert tokens._blocks == [ - (BlockTyp.SYNOPSIS, 0, "The synopsis", [], BlockFmt.NONE), - ] + assert tokens._blocks == [] assert tokens.allMarkdown[-1] == "\n" tokens.setSynopsis(True) + tokens._text = "% synopsis: The synopsis\n" tokens.tokenizeText() + assert tokens._blocks == [( + BlockTyp.SUMMARY, 0, "Synopsis: The synopsis", [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "modifier"), (9, TextFmt.B_E, ""), + (9, TextFmt.COL_E, ""), (10, TextFmt.COL_B, "synopsis"), (22, TextFmt.COL_E, "") + ], BlockFmt.NONE + )] assert tokens.allMarkdown[-1] == "% synopsis: The synopsis\n\n" # Short tokens.setSynopsis(False) tokens._text = "% short: A short description\n" tokens.tokenizeText() - assert tokens._blocks == [ - (BlockTyp.SHORT, 0, "A short description", [], BlockFmt.NONE), - ] + assert tokens._blocks == [] assert tokens.allMarkdown[-1] == "\n" tokens.setSynopsis(True) + tokens._text = "% short: A short description\n" tokens.tokenizeText() + assert tokens._blocks == [( + BlockTyp.SUMMARY, 0, "Short Description: A short description", [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "modifier"), (18, TextFmt.B_E, ""), + (18, TextFmt.COL_E, ""), (19, TextFmt.COL_B, "synopsis"), (38, TextFmt.COL_E, ""), + ], BlockFmt.NONE + )] assert tokens.allMarkdown[-1] == "% short: A short description\n\n" # Keyword @@ -1355,6 +1368,7 @@ def testFmtToken_TextIndent(mockGUI): """Test the handling of text indent in the Tokenizer class.""" project = NWProject() tokens = BareTokenizer(project) + tokens.setSynopsis(True) # No First Indent tokens.setFirstLineIndent(True, 1.0, False) @@ -1388,9 +1402,13 @@ def testFmtToken_TextIndent(mockGUI): "%Synopsis: Stuff happens.\n\n" ) tokens.tokenizeText() + tFmt = [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "modifier"), (9, TextFmt.B_E, ""), + (9, TextFmt.COL_E, ""), (10, TextFmt.COL_B, "synopsis"), (24, TextFmt.COL_E, ""), + ] assert tokens._blocks == [ (BlockTyp.HEAD3, 1, "Scene Two", [], BlockFmt.NONE), - (BlockTyp.SYNOPSIS, 1, "Stuff happens.", [], BlockFmt.NONE), + (BlockTyp.SUMMARY, 1, "Synopsis: Stuff happens.", tFmt, BlockFmt.NONE), ] assert tokens._noIndent is True @@ -1622,6 +1640,35 @@ def testFmtToken_ProcessHeaders(mockGUI): assert tokens._noSep is False +@pytest.mark.core +def testFmtToken_FormatNote(mockGUI, ipsumText): + """Test note and comment formatting.""" + project = NWProject() + project.data.setLanguage("en") + project._loadProjectLocalisation() + tokens = BareTokenizer(project) + + # Comment, No Formatting + style = COMMENT_STYLE[nwComment.PLAIN] + assert tokens._formatNote(style, "", "Hello world!") == ( + "Comment: Hello world!", [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "comment"), + (8, TextFmt.B_E, ""), (8, TextFmt.COL_E, ""), + (9, TextFmt.COL_B, "comment"), (21, TextFmt.COL_E, ""), + ] + ) + + # Synopsis, No Formatting + style = COMMENT_STYLE[nwComment.SYNOPSIS] + assert tokens._formatNote(style, "", "Hello world!") == ( + "Synopsis: Hello world!", [ + (0, TextFmt.B_B, ""), (0, TextFmt.COL_B, "modifier"), + (9, TextFmt.B_E, ""), (9, TextFmt.COL_E, ""), + (10, TextFmt.COL_B, "synopsis"), (22, TextFmt.COL_E, ""), + ] + ) + + @pytest.mark.core def testFmtToken_BuildOutline(mockGUI, ipsumText): """Test stats counter of the Tokenizer class.""" @@ -1763,7 +1810,7 @@ def testFmtToken_CountStats(mockGUI, ipsumText): tokens.setSynopsis(True) tokens.tokenizeText() tokens.countStats() - assert [t[2] for t in tokens._blocks] == ["Chapter", "Stuff", "Text"] + assert [t[2] for t in tokens._blocks] == ["Chapter", "Synopsis: Stuff", "Text"] assert tokens.textStats == { "titleCount": 1, "paragraphCount": 1, "allWords": 4, "textWords": 1, "titleWords": 1, @@ -1780,7 +1827,7 @@ def testFmtToken_CountStats(mockGUI, ipsumText): tokens.setSynopsis(True) tokens.tokenizeText() tokens.countStats() - assert [t[2] for t in tokens._blocks] == ["Chapter", "Stuff", "Text"] + assert [t[2] for t in tokens._blocks] == ["Chapter", "Short Description: Stuff", "Text"] assert tokens.textStats == { "titleCount": 1, "paragraphCount": 1, "allWords": 5, "textWords": 1, "titleWords": 1, @@ -1797,7 +1844,7 @@ def testFmtToken_CountStats(mockGUI, ipsumText): tokens.setComments(True) tokens.tokenizeText() tokens.countStats() - assert [t[2] for t in tokens._blocks] == ["Chapter", "Stuff", "Text"] + assert [t[2] for t in tokens._blocks] == ["Chapter", "Comment: Stuff", "Text"] assert tokens.textStats == { "titleCount": 1, "paragraphCount": 1, "allWords": 4, "textWords": 1, "titleWords": 1, diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py index 971cddda..c9abd3b6 100644 --- a/tests/test_formats/test_fmt_toodt.py +++ b/tests/test_formats/test_fmt_toodt.py @@ -523,12 +523,15 @@ def testFmtToOdt_ConvertParagraphs(mockGUI): '