Files
novelWriter/nw/core/tokenizer.py
T
2020-10-27 20:36:04 +01:00

688 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""novelWriter Text Tokenizer
novelWriter Text Tokenizer
==============================
Splits a piece of novelWriter markdown text into its elements
File History:
Created: 2019-05-05 [0.0.1]
This file is a part of novelWriter
Copyright 20182020, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
import re
from operator import itemgetter
from PyQt5.QtCore import QRegularExpression
from nw.core.document import NWDoc
from nw.core.tools import numberToWord, numberToRoman
from nw.constants import nwConst, nwItemLayout, nwItemType, nwRegEx
logger = logging.getLogger(__name__)
class Tokenizer():
FMT_B_B = 1 # Begin bold
FMT_B_E = 2 # End bold
FMT_I_B = 3 # Begin italics
FMT_I_E = 4 # End italics
FMT_D_B = 5 # Begin strikeout
FMT_D_E = 6 # End strikeout
T_EMPTY = 1 # Empty line (new paragraph)
T_SYNOPSIS = 2 # Synopsis comment
T_COMMENT = 3 # Comment line
T_KEYWORD = 4 # Command line
T_TITLE = 5 # Title
T_HEAD1 = 6 # Header 1
T_HEAD2 = 7 # Header 2
T_HEAD3 = 8 # Header 3
T_HEAD4 = 9 # Header 4
T_TEXT = 10 # Text line
T_SEP = 11 # Scene separator
T_SKIP = 12 # Paragraph break
A_NONE = 0 # No special style
A_LEFT = 1 # Left aligned
A_RIGHT = 2 # Right aligned
A_CENTRE = 4 # Centred
A_JUSTIFY = 8 # Justified
A_PBB = 16 # Page break before always
A_PBB_AV = 32 # Page break before avoid
A_PBB_NO = 64 # Page break before never
A_PBA = 128 # Page break after always
A_PBA_AV = 256 # Page break after avoid
A_PBA_NO = 512 # Page break after avoid
def __init__(self, theProject, theParent):
self.theProject = theProject
self.theParent = theParent
# Data Variables
self.theText = None # The raw text to be tokenized
self.theHandle = None # The handle associated with the text
self.theItem = None # The NWItem associated with the handle
self.theTokens = None # The list of the processed tokens
self.theResult = None # The result text after conversion
self.theMarkdown = None # The result text in novelWriter markdown
# User Settings
self.doBodyText = True # Include body text
self.doSynopsis = False # Also process synopsis comments
self.doComments = False # Also process comments
self.doKeywords = False # Also process keywords like tags and references
self.doJustify = False # Justify text
self.fmtTitle = "%title%" # Formatting for titles
self.fmtChapter = "%title%" # Formatting for numbered chapters
self.fmtUnNum = "%title%" # Formatting for unnumbered chapters
self.fmtScene = "%title%" # Formatting for scenes
self.fmtSection = "%title%" # Formatting for sections
self.hideScene = False # Do not include scene headers
self.hideSection = False # Do not include section headers
self.linkHeaders = False # Add an anchor before headers
# Instance Variables
self.numChapter = 0 # Counter for chapter numbers
self.numChScene = 0 # Counter for scene number within chapter
self.numAbsScene = 0 # Counter for scene number within novel
self.firstScene = False # Flag to indicate that the first scene of the chapter
# This File
self.isNone = False
self.isTitle = False
self.isBook = False
self.isPage = False
self.isPart = False
self.isUnNum = False
self.isChap = False
self.isScene = False
self.isNote = False
self.isNovel = False
# Error Handling
self.errData = []
return
##
# Setters
##
def setTitleFormat(self, fmtTitle):
self.fmtTitle = fmtTitle
return
def setChapterFormat(self, fmtChapter):
self.fmtChapter = fmtChapter
return
def setUnNumberedFormat(self, fmtUnNum):
self.fmtUnNum = fmtUnNum
return
def setSceneFormat(self, fmtScene, hideScene):
self.fmtScene = fmtScene
self.hideScene = hideScene
return
def setSectionFormat(self, fmtSection, hideSection):
self.fmtSection = fmtSection
self.hideSection = hideSection
return
def setLinkHeaders(self, linkHeaders):
self.linkHeaders = linkHeaders
return
def setBodyText(self, doBodyText):
self.doBodyText = doBodyText
return
def setSynopsis(self, doSynopsis):
self.doSynopsis = doSynopsis
return
def setComments(self, doComments):
self.doComments = doComments
return
def setKeywords(self, doKeywords):
self.doKeywords = doKeywords
return
def setJustify(self, doJustify):
self.doJustify = doJustify
return
##
# Class Methods
##
def addRootHeading(self, theHandle):
"""Add a heading at the start if a new root folder.
"""
theItem = self.theProject.projTree[theHandle]
if theItem is None:
return False
if theItem.itemType != nwItemType.ROOT:
return False
theTitle = "Notes: %s" % theItem.itemName
self.theTokens = []
self.theTokens.append((
self.T_TITLE, 0, theTitle, None, self.A_PBB | self.A_CENTRE
))
self.theMarkdown = "# %s\n\n" % theTitle
return True
def setText(self, theHandle, theText=None):
"""Set the text for the tokenizer from a handle. If theText is
not set, load it from the file.
"""
self.theHandle = theHandle
self.theItem = self.theProject.projTree[theHandle]
if self.theItem is None:
return
if theText is not None:
# If the text is set, just use that
self.theText = theText
else:
# Otherwise, load it from file
theDocument = NWDoc(self.theProject, self.theParent)
self.theText = theDocument.openDocument(theHandle)
docSize = len(self.theText)
if docSize > nwConst.MAX_DOCSIZE:
errVal = "Document '%s' is too big (%.2f MB). Skipping." % (
self.theItem.itemName, docSize/1.0e6
)
self.theText = "# ERROR\n\n%s\n\n" % errVal
self.errData.append(errVal)
self.isNone = self.theItem.itemLayout == nwItemLayout.NO_LAYOUT
self.isTitle = self.theItem.itemLayout == nwItemLayout.TITLE
self.isBook = self.theItem.itemLayout == nwItemLayout.BOOK
self.isPage = self.theItem.itemLayout == nwItemLayout.PAGE
self.isPart = self.theItem.itemLayout == nwItemLayout.PARTITION
self.isUnNum = self.theItem.itemLayout == nwItemLayout.UNNUMBERED
self.isChap = self.theItem.itemLayout == nwItemLayout.CHAPTER
self.isScene = self.theItem.itemLayout == nwItemLayout.SCENE
self.isNote = self.theItem.itemLayout == nwItemLayout.NOTE
self.isNovel = self.isBook or self.isUnNum or self.isChap or self.isScene
return
def getResult(self):
"""Return the result from the conversion.
"""
return self.theResult
def getResultSize(self):
"""Return the size of the result from the conversion.
"""
return len(self.theResult)
def getFilteredMarkdown(self):
"""Return the novelWriter markdown after the filters have been applied.
"""
return self.theMarkdown
def doAutoReplace(self):
"""Run through the user's auto-replace dictionary.
"""
if len(self.theProject.autoReplace) > 0:
repDict = {}
for aKey, aVal in self.theProject.autoReplace.items():
repDict["<%s>" % aKey] = aVal
xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL)
self.theText = xRep.sub(lambda x: repDict[x.group(0)], self.theText)
return
def doPostProcessing(self):
"""Do some postprocessing. Overloaded by subclasses. This just
does the standard escaped characters.
"""
escapeDict = {
r"\*" : "*",
r"\~" : "~",
r"\_" : "_",
}
escReplace = re.compile(
"|".join([re.escape(k) for k in escapeDict.keys()]), flags=re.DOTALL
)
self.theResult = escReplace.sub(
lambda x: escapeDict[x.group(0)], self.theResult
)
return
def tokenizeText(self):
"""Scan the text for either lines starting with specific
characters that indicate headers, comments, commands etc, or
just contains plain text. in the case of plain text, apply the
same RegExes that the syntax highlighter uses and save the
locations of these formatting tags into the token array.
The format of the token list is an entry with a four-tuple for
each line in the file. The tuple is as follows:
1: The type of the block, self.T_*
2: The line in file where this block occurred
3: The text content of the block, without leading tags
4: The internal formatting map of the text, self.FMT_*
5: The style of the block, self.A_*
"""
# RegExes for adding formatting tags within text lines
rxFormats = [
(QRegularExpression(nwRegEx.FMT_EI), [None, self.FMT_I_B, None, self.FMT_I_E]),
(QRegularExpression(nwRegEx.FMT_EB), [None, self.FMT_B_B, None, self.FMT_B_E]),
(QRegularExpression(nwRegEx.FMT_ST), [None, self.FMT_D_B, None, self.FMT_D_E]),
]
self.theTokens = []
self.theMarkdown = ""
tmpMarkdown = []
nLine = 0
for aLine in self.theText.splitlines():
nLine += 1
# Tag lines starting with specific characters
if len(aLine.strip()) == 0:
self.theTokens.append((
self.T_EMPTY,
nLine,
"",
None,
self.A_NONE
))
tmpMarkdown.append("\n")
elif aLine[0] == "%":
cLine = aLine[1:].lstrip()
synTag = cLine[:9].lower()
if synTag == "synopsis:":
self.theTokens.append((
self.T_SYNOPSIS,
nLine,
cLine[9:].strip(),
None,
self.A_NONE
))
if self.doSynopsis:
tmpMarkdown.append("%s\n" % aLine)
else:
self.theTokens.append((
self.T_COMMENT,
nLine,
aLine[1:].strip(),
None,
self.A_NONE
))
if self.doComments:
tmpMarkdown.append("%s\n" % aLine)
elif aLine[0] == "@":
self.theTokens.append((
self.T_KEYWORD,
nLine,
aLine[1:].strip(),
None,
self.A_NONE
))
if self.doKeywords:
tmpMarkdown.append("%s\n" % aLine)
elif aLine[:2] == "# ":
self.theTokens.append((
self.T_HEAD1,
nLine,
aLine[2:].strip(),
None,
self.A_NONE
))
tmpMarkdown.append("%s\n" % aLine)
elif aLine[:3] == "## ":
self.theTokens.append((
self.T_HEAD2,
nLine,
aLine[3:].strip(),
None,
self.A_NONE
))
tmpMarkdown.append("%s\n" % aLine)
elif aLine[:4] == "### ":
self.theTokens.append((
self.T_HEAD3,
nLine,
aLine[4:].strip(),
None,
self.A_NONE
))
tmpMarkdown.append("%s\n" % aLine)
elif aLine[:5] == "#### ":
self.theTokens.append((
self.T_HEAD4,
nLine,
aLine[5:].strip(),
None,
self.A_NONE
))
tmpMarkdown.append("%s\n" % aLine)
else:
if not self.doBodyText:
# Skip all body text
continue
# Otherwise we use RegEx to find formatting tags within a line of text
fmtPos = []
for theRX, theKeys in rxFormats:
rxThis = theRX.globalMatch(aLine, 0)
while rxThis.hasNext():
rxMatch = rxThis.next()
for n in range(1, len(theKeys)):
if theKeys[n] is not None:
xPos = rxMatch.capturedStart(n)
xLen = rxMatch.capturedLength(n)
fmtPos.append([xPos, xLen, theKeys[n]])
# Save the line as is, but append the array of formatting locations
# sorted by position
fmtPos = sorted(fmtPos, key=itemgetter(0))
self.theTokens.append((
self.T_TEXT,
nLine,
aLine,
fmtPos,
self.A_NONE
))
tmpMarkdown.append("%s\n" % aLine)
# Always add an empty line at the end
self.theTokens.append((
self.T_EMPTY,
nLine,
"",
None,
self.A_NONE
))
tmpMarkdown.append("\n")
self.theMarkdown = "".join(tmpMarkdown)
tmpMarkdown = []
return
def doHeaders(self):
"""Apply formatting to the text headers according to document
layout and user settings.
"""
# No special header formatting for notes and no-layout files
if self.isNone or self.isNote:
return
# For novel files, we need to handle chapter numbering, scene
# numbering, and scene breaks
if self.isNovel:
for n in range(len(self.theTokens)):
tToken = self.theTokens[n]
# In case we see text before a scene, we reset the flag
if tToken[0] == self.T_TEXT:
self.firstScene = False
elif tToken[0] == self.T_HEAD1:
# Main Title
# ==========
tTemp = self._formatHeading(self.fmtTitle, tToken[2])
self.theTokens[n] = (
tToken[0],
tToken[1],
tTemp,
None,
self.A_NONE
)
elif tToken[0] == self.T_HEAD2:
# Novel Chapter
# =============
# Numbered or Unnumbered
if self.isUnNum:
tTemp = self._formatHeading(self.fmtUnNum, tToken[2])
elif tToken[2].startswith("*"):
tTemp = self._formatHeading(self.fmtUnNum, tToken[2])
tTemp = tTemp[1:].lstrip()
else:
self.numChapter += 1
tTemp = self._formatHeading(self.fmtChapter, tToken[2])
# Format the chapter header
self.theTokens[n] = (
tToken[0],
tToken[1],
tTemp,
None,
self.A_PBB
)
# Set scene variables
self.firstScene = True
self.numChScene = 0
elif tToken[0] == self.T_HEAD3:
# Novel Scene
# ===========
self.numChScene += 1
self.numAbsScene += 1
tTemp = self._formatHeading(self.fmtScene, tToken[2])
if tTemp == "" and self.hideScene:
self.theTokens[n] = (
self.T_EMPTY,
tToken[1],
"",
None,
self.A_NONE
)
elif tTemp == "" and not self.hideScene:
if self.firstScene:
self.theTokens[n] = (
self.T_EMPTY,
tToken[1],
"",
None,
self.A_NONE
)
else:
self.theTokens[n] = (
self.T_SKIP,
tToken[1],
"",
None,
self.A_NONE
)
elif tTemp == self.fmtScene:
if self.firstScene:
self.theTokens[n] = (
self.T_EMPTY,
tToken[1],
"",
None,
self.A_NONE
)
else:
self.theTokens[n] = (
self.T_SEP,
tToken[1],
tTemp,
None,
self.A_CENTRE
)
else:
self.theTokens[n] = (
tToken[0],
tToken[1],
tTemp,
None,
self.A_NONE
)
# Definitely no longer the first scene
self.firstScene = False
elif tToken[0] == self.T_HEAD4:
# Novel Section
# =============
tTemp = self._formatHeading(self.fmtSection, tToken[2])
if tTemp == "" and self.hideSection:
self.theTokens[n] = (
self.T_EMPTY,
tToken[1],
"",
None,
self.A_NONE
)
elif tTemp == "" and not self.hideSection:
self.theTokens[n] = (
self.T_SKIP,
tToken[1],
"",
None,
self.A_NONE
)
elif tTemp == self.fmtSection:
self.theTokens[n] = (
self.T_SEP,
tToken[1],
tTemp,
None,
self.A_CENTRE
)
else:
self.theTokens[n] = (
tToken[0],
tToken[1],
tTemp,
None,
self.A_NONE
)
# For title page and partitions, we need to centre all text.
# For partition, we also add a page break before, and for
# both types we always add a page break after the content.
# We also swap header level 1 with a title type instead.
if self.isTitle or self.isPart:
for n, tToken in enumerate(self.theTokens):
if tToken[0] == self.T_HEAD1:
if self.isTitle:
self.theTokens[n] = (
self.T_TITLE,
tToken[1],
tToken[2],
tToken[3],
self.A_PBB_NO | self.A_CENTRE
)
else:
self.theTokens[n] = (
tToken[0],
tToken[1],
tToken[2],
tToken[3],
self.A_PBB | self.A_CENTRE
)
else:
self.theTokens[n] = (
tToken[0],
tToken[1],
tToken[2],
tToken[3],
self.A_CENTRE
)
# Add a page break after the last entry
n = len(self.theTokens) - 1
if n >= 0:
tToken = self.theTokens[n]
self.theTokens[n] = (
tToken[0],
tToken[1],
tToken[2],
tToken[3],
tToken[4] | self.A_PBA
)
# A single page is always left-aligned and starts on a fresh
# page, unless it's empty.
if self.isPage:
for n, tToken in enumerate(self.theTokens):
if n == 0:
self.theTokens[n] = (
tToken[0],
tToken[1],
tToken[2],
tToken[3],
self.A_LEFT | self.A_PBB
)
else:
self.theTokens[n] = (
tToken[0],
tToken[1],
tToken[2],
tToken[3],
self.A_LEFT
)
return
##
# Internal Functions
##
def _formatHeading(self, theTitle, theText):
"""Replaces the %keyword% strings.
"""
theTitle = theTitle.replace(r"%title%", theText)
theTitle = theTitle.replace(r"%ch%", str(self.numChapter))
theTitle = theTitle.replace(r"%sc%", str(self.numChScene))
theTitle = theTitle.replace(r"%sca%", str(self.numAbsScene))
if r"%chw%" in theTitle:
theTitle = theTitle.replace(r"%chw%", numberToWord(self.numChapter, "en"))
if r"%chi%" in theTitle:
theTitle = theTitle.replace(r"%chi%", numberToRoman(self.numChapter, True))
if r"%chI%" in theTitle:
theTitle = theTitle.replace(r"%chI%", numberToRoman(self.numChapter, False))
return theTitle
# END Class Tokenizer