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
|
# 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 '
|
||||||
|
|||||||
Reference in New Issue
Block a user