Separate spell checking and link scanning, and add ODT support for links

This commit is contained in:
Veronica Berglyd Olsen
2024-10-25 19:30:22 +02:00
parent b13b91c8e8
commit fe56c88309
4 changed files with 84 additions and 35 deletions
+33 -10
View File
@@ -49,14 +49,15 @@ logger = logging.getLogger(__name__)
# Main XML NameSpaces # Main XML NameSpaces
XML_NS = { 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", "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", "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style": "urn:oasis:names:tc:opendocument:xmlns:style: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", "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
"meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "xlink": "http://www.w3.org/1999/xlink",
"fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
"dc": "http://purl.org/dc/elements/1.1/",
} }
for ns, uri in XML_NS.items(): for ns, uri in XML_NS.items():
ET.register_namespace(ns, uri) ET.register_namespace(ns, uri)
@@ -80,7 +81,6 @@ TAG_SPC = _mkTag("text", "s")
TAG_NSPC = _mkTag("text", "c") TAG_NSPC = _mkTag("text", "c")
TAG_TAB = _mkTag("text", "tab") TAG_TAB = _mkTag("text", "tab")
TAG_SPAN = _mkTag("text", "span") TAG_SPAN = _mkTag("text", "span")
TAG_STNM = _mkTag("text", "style-name")
# Formatting Codes # Formatting Codes
X_BLD = 0x001 # Bold format X_BLD = 0x001 # Bold format
@@ -91,6 +91,7 @@ X_MRK = 0x010 # Marked format
X_SUP = 0x020 # Superscript X_SUP = 0x020 # Superscript
X_SUB = 0x040 # Subscript X_SUB = 0x040 # Subscript
X_COL = 0x080 # Coloured text X_COL = 0x080 # Coloured text
X_HRF = 0x100 # Link
# Formatting Masks # Formatting Masks
M_BLD = ~X_BLD M_BLD = ~X_BLD
@@ -101,6 +102,7 @@ M_MRK = ~X_MRK
M_SUP = ~X_SUP M_SUP = ~X_SUP
M_SUB = ~X_SUB M_SUB = ~X_SUB
M_COL = ~X_COL M_COL = ~X_COL
M_HRF = ~X_HRF
# ODT Styles # ODT Styles
S_TITLE = "Title" S_TITLE = "Title"
@@ -570,6 +572,7 @@ class ToOdt(Tokenizer):
fLast = 0 fLast = 0
xNode = None xNode = None
fClass = "" fClass = ""
fLink = ""
for fPos, fFmt, fData in tFmt or []: for fPos, fFmt, fData in tFmt or []:
# Add any extra nodes # Add any extra nodes
@@ -582,7 +585,7 @@ class ToOdt(Tokenizer):
if xFmt == 0x00: if xFmt == 0x00:
parProc.appendText(tFrag) parProc.appendText(tFrag)
else: else:
parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass), fLink)
# Calculate the change of format # Calculate the change of format
if fFmt == TextFmt.B_B: if fFmt == TextFmt.B_B:
@@ -619,6 +622,12 @@ class ToOdt(Tokenizer):
elif fFmt == TextFmt.COL_E: elif fFmt == TextFmt.COL_E:
xFmt &= M_COL xFmt &= M_COL
fClass = "" fClass = ""
elif fFmt == TextFmt.HRF_B:
xFmt |= X_HRF
fLink = fData
elif fFmt == TextFmt.HRF_E:
xFmt &= M_HRF
fLink = ""
elif fFmt == TextFmt.FNOTE: elif fFmt == TextFmt.FNOTE:
xNode = self._generateFootnote(fData) xNode = self._generateFootnote(fData)
elif fFmt == TextFmt.STRIP: elif fFmt == TextFmt.STRIP:
@@ -633,7 +642,7 @@ class ToOdt(Tokenizer):
if xFmt == 0x00: if xFmt == 0x00:
parProc.appendText(tFrag) parProc.appendText(tFrag)
else: else:
parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass)) parProc.appendSpan(tFrag, self._textStyle(xFmt, fClass), fLink)
nErr, errMsg = parProc.checkError() nErr, errMsg = parProc.checkError()
if nErr > 0: # pragma: no cover if nErr > 0: # pragma: no cover
@@ -692,6 +701,11 @@ class ToOdt(Tokenizer):
style.setTextPosition("sub") style.setTextPosition("sub")
if hFmt & X_COL and color: if hFmt & X_COL and color:
style.setColor(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 self._autoText[tKey] = style
return style.name return style.name
@@ -1497,13 +1511,22 @@ class XMLParagraph:
return 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 """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 Therefore we return to the root element level when we're done
processing the text of the span. 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.text = "" # Defaults to None
self._xTail.tail = "" # Defaults to None self._xTail.tail = "" # Defaults to None
self._nState = X_SPAN_TEXT self._nState = X_SPAN_TEXT
+11 -3
View File
@@ -330,12 +330,20 @@ class ToQTextDocument(Tokenizer):
cFmt.setAnchorNames([data]) cFmt.setAnchorNames([data])
elif fmt == TextFmt.ANM_E: elif fmt == TextFmt.ANM_E:
cFmt.setAnchor(False) cFmt.setAnchor(False)
elif fmt in (TextFmt.HRF_B, TextFmt.ARF_B): elif fmt == TextFmt.ARF_B:
cFmt.setForeground(primary or self._theme.link)
cFmt.setFontUnderline(True) cFmt.setFontUnderline(True)
cFmt.setAnchor(True) cFmt.setAnchor(True)
cFmt.setAnchorHref(data) 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.setForeground(primary or self._theme.text)
cFmt.setFontUnderline(False) cFmt.setFontUnderline(False)
cFmt.setAnchor(False) cFmt.setAnchor(False)
+18 -13
View File
@@ -428,6 +428,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
data = TextBlockData() data = TextBlockData()
self.setCurrentBlockUserData(data) self.setCurrentBlockUserData(data)
data.extractMetaData(text, xOff)
if self._spellCheck: if self._spellCheck:
for xPos, xEnd in data.spellCheck(text, xOff): for xPos, xEnd in data.spellCheck(text, xOff):
for x in range(xPos, xEnd): for x in range(xPos, xEnd):
@@ -496,20 +497,9 @@ class TextBlockData(QTextBlockUserData):
"""Return spell error data from last check.""" """Return spell error data from last check."""
return self._spellErrors return self._spellErrors
def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]: def extractMetaData(self, text: str, offset: int) -> None:
"""Run the spell checker and cache the result, and return the """Extract meta data from the text."""
list of spell check errors.
"""
self._metaData = [] 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: if "http" in text:
# Strip URLs # Strip URLs
for res in RX_URL.finditer(text, offset): for res in RX_URL.finditer(text, offset):
@@ -518,6 +508,21 @@ class TextBlockData(QTextBlockUserData):
text = f"{text[:s]}{pad}{text[e:]}" text = f"{text[:s]}{pad}{text[e:]}"
self._metaData.append((s, e, res.group(0), "url")) 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 checker = SHARED.spelling
for res in RX_WORDS.finditer(text.replace("_", " "), offset): for res in RX_WORDS.finditer(text.replace("_", " "), offset):
if ( if (
+22 -9
View File
@@ -38,13 +38,14 @@ from novelwriter.formats.toodt import ODTParagraphStyle, ODTTextStyle, ToOdt, XM
from tests.tools import ODT_IGNORE, cmpFiles from tests.tools import ODT_IGNORE, cmpFiles
XML_NS = [ 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:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
' xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style: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:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"',
' xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"', ' xmlns:xlink="http://www.w3.org/1999/xlink"',
' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"',
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
] ]
@@ -1301,7 +1302,7 @@ def testFmtToOdt_XMLParagraph():
) )
# Text Span # Text Span
xmlPar.appendSpan("spanned text", "T1") xmlPar.appendSpan("spanned text", "T1", "")
assert xmlToText(xRoot) == ( assert xmlToText(xRoot) == (
'<root>' '<root>'
'<text:p>Hello World' '<text:p>Hello World'
@@ -1338,7 +1339,7 @@ def testFmtToOdt_XMLParagraph():
) )
# Text Span w/Line Break # Text Span w/Line Break
xmlPar.appendSpan("spanned\ntext", "T1") xmlPar.appendSpan("spanned\ntext", "T1", "")
assert xmlToText(xRoot) == ( assert xmlToText(xRoot) == (
'<root>' '<root>'
'<text:p>Hello<text:line-break />World<text:line-break />!!' '<text:p>Hello<text:line-break />World<text:line-break />!!'
@@ -1374,7 +1375,7 @@ def testFmtToOdt_XMLParagraph():
) )
# Text Span w/Line Break # Text Span w/Line Break
xmlPar.appendSpan("spanned\ttext", "T1") xmlPar.appendSpan("spanned\ttext", "T1", "")
assert xmlToText(xRoot) == ( assert xmlToText(xRoot) == (
'<root>' '<root>'
'<text:p>Hello<text:tab />World<text:tab />!!' '<text:p>Hello<text:tab />World<text:tab />!!'
@@ -1392,6 +1393,18 @@ def testFmtToOdt_XMLParagraph():
'</root>' '</root>'
) )
# Tail Text w/Link
xmlPar.appendSpan("Example", "T1", "http://www.example.com")
assert xmlToText(xRoot) == (
'<root>'
'<text:p>Hello<text:tab />World<text:tab />!!'
'<text:span text:style-name="T1">spanned<text:tab />text</text:span>'
'more<text:tab />text'
'<text:a xlink:type="simple" xlink:href="http://www.example.com" text:style-name="T1">'
'Example</text:a></text:p>'
'</root>'
)
assert xmlPar.checkError() == (0, "") assert xmlPar.checkError() == (0, "")
# Stage 4 : Spaces # Stage 4 : Spaces
@@ -1410,7 +1423,7 @@ def testFmtToOdt_XMLParagraph():
) )
# Text Span w/Spaces # Text Span w/Spaces
xmlPar.appendSpan("spanned text", "T1") xmlPar.appendSpan("spanned text", "T1", "")
assert xmlToText(xRoot) == ( assert xmlToText(xRoot) == (
'<root>' '<root>'
'<text:p>Hello <text:s />World <text:s text:c="2" />!!' '<text:p>Hello <text:s />World <text:s text:c="2" />!!'
@@ -1446,7 +1459,7 @@ def testFmtToOdt_XMLParagraph():
) )
# Text Span w/Many Spaces # Text Span w/Many Spaces
xmlPar.appendSpan(" C \t D \n E ", "T1") xmlPar.appendSpan(" C \t D \n E ", "T1", "")
assert xmlToText(xRoot) == ( assert xmlToText(xRoot) == (
'<root>' '<root>'
'<text:p><text:s text:c="2" /><text:tab /> A <text:line-break /> <text:s />B ' '<text:p><text:s text:c="2" /><text:tab /> A <text:line-break /> <text:s />B '