From fe56c88309762fa87be5278444f64f004855da89 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 25 Oct 2024 19:30:22 +0200 Subject: [PATCH] Separate spell checking and link scanning, and add ODT support for links --- novelwriter/formats/toodt.py | 43 +++++++++++++++++++++------- novelwriter/formats/toqdoc.py | 14 +++++++-- novelwriter/gui/dochighlight.py | 31 +++++++++++--------- tests/test_formats/test_fmt_toodt.py | 31 ++++++++++++++------ 4 files changed, 84 insertions(+), 35 deletions(-) diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py index 4edce58b..afb15203 100644 --- a/novelwriter/formats/toodt.py +++ b/novelwriter/formats/toodt.py @@ -49,14 +49,15 @@ logger = logging.getLogger(__name__) # Main XML NameSpaces XML_NS = { + "dc": "http://purl.org/dc/elements/1.1/", + "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", + "loext": "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0", "manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", + "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", - "loext": "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0", "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", - "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", - "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", - "dc": "http://purl.org/dc/elements/1.1/", + "xlink": "http://www.w3.org/1999/xlink", } for ns, uri in XML_NS.items(): ET.register_namespace(ns, uri) @@ -80,7 +81,6 @@ TAG_SPC = _mkTag("text", "s") TAG_NSPC = _mkTag("text", "c") TAG_TAB = _mkTag("text", "tab") TAG_SPAN = _mkTag("text", "span") -TAG_STNM = _mkTag("text", "style-name") # Formatting Codes X_BLD = 0x001 # Bold format @@ -91,6 +91,7 @@ X_MRK = 0x010 # Marked format X_SUP = 0x020 # Superscript X_SUB = 0x040 # Subscript X_COL = 0x080 # Coloured text +X_HRF = 0x100 # Link # Formatting Masks M_BLD = ~X_BLD @@ -101,6 +102,7 @@ M_MRK = ~X_MRK M_SUP = ~X_SUP M_SUB = ~X_SUB M_COL = ~X_COL +M_HRF = ~X_HRF # ODT Styles S_TITLE = "Title" @@ -570,6 +572,7 @@ class ToOdt(Tokenizer): fLast = 0 xNode = None fClass = "" + fLink = "" for fPos, fFmt, fData in tFmt or []: # Add any extra nodes @@ -582,7 +585,7 @@ class ToOdt(Tokenizer): if xFmt == 0x00: parProc.appendText(tFrag) else: - parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) + parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass), fLink) # Calculate the change of format if fFmt == TextFmt.B_B: @@ -619,6 +622,12 @@ class ToOdt(Tokenizer): elif fFmt == TextFmt.COL_E: xFmt &= M_COL fClass = "" + elif fFmt == TextFmt.HRF_B: + xFmt |= X_HRF + fLink = fData + elif fFmt == TextFmt.HRF_E: + xFmt &= M_HRF + fLink = "" elif fFmt == TextFmt.FNOTE: xNode = self._generateFootnote(fData) elif fFmt == TextFmt.STRIP: @@ -633,7 +642,7 @@ class ToOdt(Tokenizer): if xFmt == 0x00: parProc.appendText(tFrag) else: - parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) + parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass), fLink) nErr, errMsg = parProc.checkError() if nErr > 0: # pragma: no cover @@ -692,6 +701,11 @@ class ToOdt(Tokenizer): style.setTextPosition("sub") if hFmt & X_COL and color: style.setColor(color) + if hFmt & X_HRF: + style.setColor(self._theme.link) + style.setUnderlineStyle("solid") + style.setUnderlineWidth("auto") + style.setUnderlineColor("font-color") self._autoText[tKey] = style return style.name @@ -1497,13 +1511,22 @@ class XMLParagraph: return - def appendSpan(self, text: str, fmt: str) -> None: + def appendSpan(self, text: str, style: str, link: str) -> None: """Append a text span to the XML element. The span is always - closed since we do not allow nested spans (like Libre Office). + closed since we do not produce nested spans (like Libre Office). Therefore we return to the root element level when we're done processing the text of the span. """ - self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={TAG_STNM: fmt}) + if link: + self._xTail = ET.SubElement(self._xRoot, _mkTag("text", "a"), attrib={ + _mkTag("xlink", "type"): "simple", + _mkTag("xlink", "href"): link, + _mkTag("text", "style-name"): style, + }) + else: + self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={ + _mkTag("text", "style-name"): style, + }) self._xTail.text = "" # Defaults to None self._xTail.tail = "" # Defaults to None self._nState = X_SPAN_TEXT diff --git a/novelwriter/formats/toqdoc.py b/novelwriter/formats/toqdoc.py index 653e8207..84745c58 100644 --- a/novelwriter/formats/toqdoc.py +++ b/novelwriter/formats/toqdoc.py @@ -330,12 +330,20 @@ class ToQTextDocument(Tokenizer): cFmt.setAnchorNames([data]) elif fmt == TextFmt.ANM_E: cFmt.setAnchor(False) - elif fmt in (TextFmt.HRF_B, TextFmt.ARF_B): - cFmt.setForeground(primary or self._theme.link) + elif fmt == TextFmt.ARF_B: cFmt.setFontUnderline(True) cFmt.setAnchor(True) cFmt.setAnchorHref(data) - elif fmt in (TextFmt.HRF_E, TextFmt.ARF_E): + elif fmt == TextFmt.ARF_E: + cFmt.setFontUnderline(False) + cFmt.setAnchor(False) + cFmt.setAnchorHref("") + elif fmt == TextFmt.HRF_B: + cFmt.setForeground(self._theme.link) + cFmt.setFontUnderline(True) + cFmt.setAnchor(True) + cFmt.setAnchorHref(data) + elif fmt == TextFmt.HRF_E: cFmt.setForeground(primary or self._theme.text) cFmt.setFontUnderline(False) cFmt.setAnchor(False) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index c06962b8..034b877d 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -428,6 +428,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): data = TextBlockData() self.setCurrentBlockUserData(data) + data.extractMetaData(text, xOff) if self._spellCheck: for xPos, xEnd in data.spellCheck(text, xOff): for x in range(xPos, xEnd): @@ -496,20 +497,9 @@ class TextBlockData(QTextBlockUserData): """Return spell error data from last check.""" return self._spellErrors - def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]: - """Run the spell checker and cache the result, and return the - list of spell check errors. - """ + def extractMetaData(self, text: str, offset: int) -> None: + """Extract meta data from the text.""" self._metaData = [] - self._spellErrors = [] - if "[" in text: - # Strip shortcodes - for regEx in [RX_FMT_SC, RX_FMT_SV]: - for res in regEx.finditer(text, offset): - if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0: - pad = " "*(e - s) - text = f"{text[:s]}{pad}{text[e:]}" - if "http" in text: # Strip URLs for res in RX_URL.finditer(text, offset): @@ -518,6 +508,21 @@ class TextBlockData(QTextBlockUserData): text = f"{text[:s]}{pad}{text[e:]}" self._metaData.append((s, e, res.group(0), "url")) + return + + def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]: + """Run the spell checker and cache the result, and return the + list of spell check errors. + """ + if "[" in text: + # Strip shortcodes + for regEx in [RX_FMT_SC, RX_FMT_SV]: + for res in regEx.finditer(text, offset): + if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0: + pad = " "*(e - s) + text = f"{text[:s]}{pad}{text[e:]}" + + self._spellErrors = [] checker = SHARED.spelling for res in RX_WORDS.finditer(text.replace("_", " "), offset): if ( diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py index fedb5f2a..a6ebf6b7 100644 --- a/tests/test_formats/test_fmt_toodt.py +++ b/tests/test_formats/test_fmt_toodt.py @@ -38,13 +38,14 @@ from novelwriter.formats.toodt import ODTParagraphStyle, ODTTextStyle, ToOdt, XM from tests.tools import ODT_IGNORE, cmpFiles XML_NS = [ + ' xmlns:dc="http://purl.org/dc/elements/1.1/"', + ' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"', + ' xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0"', + ' xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"', ' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"', ' xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"', - ' xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0"', ' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"', - ' xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"', - ' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"', - ' xmlns:dc="http://purl.org/dc/elements/1.1/"' + ' xmlns:xlink="http://www.w3.org/1999/xlink"', ] @@ -1301,7 +1302,7 @@ def testFmtToOdt_XMLParagraph(): ) # Text Span - xmlPar.appendSpan("spanned text", "T1") + xmlPar.appendSpan("spanned text", "T1", "") assert xmlToText(xRoot) == ( '' 'Hello World' @@ -1338,7 +1339,7 @@ def testFmtToOdt_XMLParagraph(): ) # Text Span w/Line Break - xmlPar.appendSpan("spanned\ntext", "T1") + xmlPar.appendSpan("spanned\ntext", "T1", "") assert xmlToText(xRoot) == ( '' 'HelloWorld!!' @@ -1374,7 +1375,7 @@ def testFmtToOdt_XMLParagraph(): ) # Text Span w/Line Break - xmlPar.appendSpan("spanned\ttext", "T1") + xmlPar.appendSpan("spanned\ttext", "T1", "") assert xmlToText(xRoot) == ( '' 'HelloWorld!!' @@ -1392,6 +1393,18 @@ def testFmtToOdt_XMLParagraph(): '' ) + # Tail Text w/Link + xmlPar.appendSpan("Example", "T1", "http://www.example.com") + assert xmlToText(xRoot) == ( + '' + 'HelloWorld!!' + 'spannedtext' + 'moretext' + '' + 'Example' + '' + ) + assert xmlPar.checkError() == (0, "") # Stage 4 : Spaces @@ -1410,7 +1423,7 @@ def testFmtToOdt_XMLParagraph(): ) # Text Span w/Spaces - xmlPar.appendSpan("spanned text", "T1") + xmlPar.appendSpan("spanned text", "T1", "") assert xmlToText(xRoot) == ( '' 'Hello World !!' @@ -1446,7 +1459,7 @@ def testFmtToOdt_XMLParagraph(): ) # Text Span w/Many Spaces - xmlPar.appendSpan(" C \t D \n E ", "T1") + xmlPar.appendSpan(" C \t D \n E ", "T1", "") assert xmlToText(xRoot) == ( '' ' A B '