Separate spell checking and link scanning, and add ODT support for links
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) == (
|
||||
'<root>'
|
||||
'<text:p>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) == (
|
||||
'<root>'
|
||||
'<text:p>Hello<text:line-break />World<text:line-break />!!'
|
||||
@@ -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) == (
|
||||
'<root>'
|
||||
'<text:p>Hello<text:tab />World<text:tab />!!'
|
||||
@@ -1392,6 +1393,18 @@ def testFmtToOdt_XMLParagraph():
|
||||
'</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, "")
|
||||
|
||||
# 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) == (
|
||||
'<root>'
|
||||
'<text:p>Hello <text:s />World <text:s text:c="2" />!!'
|
||||
@@ -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) == (
|
||||
'<root>'
|
||||
'<text:p><text:s text:c="2" /><text:tab /> A <text:line-break /> <text:s />B '
|
||||
|
||||
Reference in New Issue
Block a user