1215 lines
48 KiB
Python
1215 lines
48 KiB
Python
"""
|
||
novelWriter – Text Tokenizer
|
||
============================
|
||
|
||
File History:
|
||
Created: 2019-05-05 [0.0.1] Tokenizer
|
||
Created: 2023-05-23 [2.1b1] HeadingFormatter
|
||
|
||
This file is a part of novelWriter
|
||
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
|
||
|
||
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/>.
|
||
""" # noqa
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
|
||
from abc import ABC, abstractmethod
|
||
from typing import TYPE_CHECKING, NamedTuple
|
||
|
||
from PyQt6.QtCore import QLocale
|
||
from PyQt6.QtGui import QColor, QFont
|
||
|
||
from novelwriter import CONFIG
|
||
from novelwriter.common import checkInt, fontMatcher, numberToRoman
|
||
from novelwriter.constants import (
|
||
nwHeadFmt, nwKeyWords, nwLabels, nwShortcode, nwStats, nwStyles, nwUnicode,
|
||
trConst
|
||
)
|
||
from novelwriter.enum import nwComment, nwItemLayout
|
||
from novelwriter.formats.shared import (
|
||
BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt
|
||
)
|
||
from novelwriter.text.comments import processComment
|
||
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
|
||
|
||
if TYPE_CHECKING:
|
||
from pathlib import Path
|
||
|
||
from novelwriter.core.project import NWProject
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ComStyle(NamedTuple):
|
||
"""Comment style info."""
|
||
|
||
label: str = ""
|
||
labelClass: str = ""
|
||
textClass: str = ""
|
||
|
||
|
||
COMMENT_STYLE = {
|
||
nwComment.PLAIN: ComStyle("Comment", "comment", "comment"),
|
||
nwComment.IGNORE: ComStyle(),
|
||
nwComment.SYNOPSIS: ComStyle("Synopsis", "modifier", "note"),
|
||
nwComment.SHORT: ComStyle("Short Description", "modifier", "note"),
|
||
nwComment.NOTE: ComStyle("Note", "modifier", "note"),
|
||
nwComment.FOOTNOTE: ComStyle("", "modifier", "note"),
|
||
nwComment.COMMENT: ComStyle(),
|
||
nwComment.STORY: ComStyle("Story Structure", "modifier", "note"),
|
||
}
|
||
COMMENT_TYPE = {
|
||
nwComment.PLAIN: BlockTyp.COMMENT,
|
||
nwComment.IGNORE: BlockTyp.COMMENT,
|
||
nwComment.SYNOPSIS: BlockTyp.SUMMARY,
|
||
nwComment.SHORT: BlockTyp.SUMMARY,
|
||
nwComment.NOTE: BlockTyp.NOTE,
|
||
nwComment.FOOTNOTE: BlockTyp.COMMENT,
|
||
nwComment.COMMENT: BlockTyp.COMMENT,
|
||
nwComment.STORY: BlockTyp.NOTE,
|
||
}
|
||
HEADING_BLOCKS = [
|
||
BlockTyp.TITLE, BlockTyp.PART, BlockTyp.HEAD1,
|
||
BlockTyp.HEAD2, BlockTyp.HEAD3, BlockTyp.HEAD4,
|
||
]
|
||
COMMENT_BLOCKS = (BlockTyp.COMMENT, BlockTyp.SUMMARY, BlockTyp.NOTE)
|
||
META_BLOCKS = (BlockTyp.COMMENT, BlockTyp.SUMMARY, BlockTyp.NOTE, BlockTyp.KEYWORD)
|
||
SKIP_INDENT = [*HEADING_BLOCKS, BlockTyp.SEP, BlockTyp.SKIP]
|
||
B_EMPTY: T_Block = (BlockTyp.EMPTY, "", "", [], BlockFmt.NONE)
|
||
|
||
|
||
class Tokenizer(ABC):
|
||
"""Core: Text Tokenizer Abstract Base Class.
|
||
|
||
This is the base class for all document build classes. It parses the
|
||
novelWriter markup format and generates a registry of tokens and
|
||
text that can be further processed into other output formats by
|
||
subclasses.
|
||
"""
|
||
|
||
def __init__(self, project: NWProject) -> None:
|
||
|
||
self._project = project
|
||
|
||
# Data Variables
|
||
self._text = "" # The raw text to be tokenized
|
||
self._handle = None # The item handle currently being processed
|
||
self._keepRaw = False # Whether to keep the raw text, used by ToRaw
|
||
self._noTokens = False # Disable tokenization if they're not needed
|
||
|
||
# Blocks and Meta Data (Per Document)
|
||
self._blocks: list[T_Block] = []
|
||
self._footnotes: dict[str, T_Note] = {}
|
||
|
||
# Blocks and Meta Data (Per Instance)
|
||
self._raw: list[str] = []
|
||
self._pages: list[str] = []
|
||
self._counts: dict[str, int] = {}
|
||
self._outline: dict[str, str] = {}
|
||
|
||
# User Settings
|
||
self._dLocale = CONFIG.locale # The document locale
|
||
self._textFont = QFont("Serif", 11) # Output text font
|
||
self._lineHeight = 1.15 # Line height in units of em
|
||
self._colorHeads = True # Colourise headings
|
||
self._scaleHeads = True # Scale headings to larger font size
|
||
self._boldHeads = True # Bold headings
|
||
self._blockIndent = 4.00 # Block indent in units of em
|
||
self._firstIndent = False # Enable first line indent
|
||
self._firstWidth = 1.40 # First line indent in units of em
|
||
self._indentFirst = False # Indent first paragraph
|
||
self._doJustify = False # Justify text
|
||
self._doBodyText = True # Include body text
|
||
self._doComments = set() # Comment styles to allow
|
||
self._doKeywords = False # Also process keywords like tags and references
|
||
self._keepBreaks = True # Keep line breaks in paragraphs
|
||
self._defaultAlign = "left" # The default text alignment
|
||
|
||
self._skipKeywords: set[str] = set() # Keywords to ignore
|
||
|
||
# Defaults
|
||
self._doComments.add(nwComment.FOOTNOTE)
|
||
|
||
# Other Setting
|
||
self._theme = TextDocumentTheme()
|
||
self._classes: dict[str, QColor] = {}
|
||
|
||
# Margins
|
||
self._marginTitle = nwStyles.T_MARGIN["H0"]
|
||
self._marginHead1 = nwStyles.T_MARGIN["H1"]
|
||
self._marginHead2 = nwStyles.T_MARGIN["H2"]
|
||
self._marginHead3 = nwStyles.T_MARGIN["H3"]
|
||
self._marginHead4 = nwStyles.T_MARGIN["H4"]
|
||
self._marginText = nwStyles.T_MARGIN["TT"]
|
||
self._marginMeta = nwStyles.T_MARGIN["MT"]
|
||
self._marginFoot = nwStyles.T_MARGIN["FT"]
|
||
self._marginSep = nwStyles.T_MARGIN["SP"]
|
||
|
||
# Title Formats
|
||
self._fmtPart = nwHeadFmt.TITLE # Formatting for partitions
|
||
self._fmtChapter = nwHeadFmt.TITLE # Formatting for numbered chapters
|
||
self._fmtUnNum = nwHeadFmt.TITLE # Formatting for unnumbered chapters
|
||
self._fmtScene = nwHeadFmt.TITLE # Formatting for scenes
|
||
self._fmtHScene = nwHeadFmt.TITLE # Formatting for hard scenes
|
||
self._fmtSection = nwHeadFmt.TITLE # Formatting for sections
|
||
|
||
self._hidePart = False # Do not include partition headings
|
||
self._hideChapter = False # Do not include chapter headings
|
||
self._hideUnNum = False # Do not include unnumbered headings
|
||
self._hideScene = False # Do not include scene headings
|
||
self._hideHScene = False # Do not include hard scene headings
|
||
self._hideSection = False # Do not include section headings
|
||
|
||
self._linkHeadings = False # Add an anchor before headings
|
||
|
||
self._titleStyle = BlockFmt.CENTRE | BlockFmt.PBB
|
||
self._partStyle = BlockFmt.CENTRE | BlockFmt.PBB
|
||
self._chapterStyle = BlockFmt.PBB
|
||
self._sceneStyle = BlockFmt.NONE
|
||
|
||
# Instance Variables
|
||
self._hFormatter = HeadingFormatter(self._project)
|
||
self._noSep = True # Flag to indicate that we don't want a scene separator
|
||
self._noIndent = False # Flag to disable text indent on next paragraph
|
||
self._breakNext = False # Add a page break on next token
|
||
|
||
# This File
|
||
self._isNovel = False # Document is a novel document
|
||
self._isFirst = True # Document is the first in a set
|
||
|
||
# Error Handling
|
||
self._errData = []
|
||
|
||
# Function Mapping
|
||
self._localLookup = self._project.localLookup
|
||
|
||
# Format RegEx
|
||
self._rxMarkdown = [
|
||
(REGEX_PATTERNS.markdownItalic, [0, TextFmt.I_B, 0, TextFmt.I_E]),
|
||
(REGEX_PATTERNS.markdownBold, [0, TextFmt.B_B, 0, TextFmt.B_E]),
|
||
(REGEX_PATTERNS.markdownStrike, [0, TextFmt.D_B, 0, TextFmt.D_E]),
|
||
(REGEX_PATTERNS.markdownMark, [0, TextFmt.M_B, 0, TextFmt.M_E]),
|
||
]
|
||
|
||
self._shortCodeFmt = {
|
||
nwShortcode.ITALIC_O: TextFmt.I_B, nwShortcode.ITALIC_C: TextFmt.I_E,
|
||
nwShortcode.BOLD_O: TextFmt.B_B, nwShortcode.BOLD_C: TextFmt.B_E,
|
||
nwShortcode.STRIKE_O: TextFmt.D_B, nwShortcode.STRIKE_C: TextFmt.D_E,
|
||
nwShortcode.ULINE_O: TextFmt.U_B, nwShortcode.ULINE_C: TextFmt.U_E,
|
||
nwShortcode.MARK_O: TextFmt.M_B, nwShortcode.MARK_C: TextFmt.M_E,
|
||
nwShortcode.SUP_O: TextFmt.SUP_B, nwShortcode.SUP_C: TextFmt.SUP_E,
|
||
nwShortcode.SUB_O: TextFmt.SUB_B, nwShortcode.SUB_C: TextFmt.SUB_E,
|
||
}
|
||
self._shortCodeVals = {
|
||
nwShortcode.FOOTNOTE_B: TextFmt.FNOTE,
|
||
nwShortcode.FIELD_B: TextFmt.FIELD,
|
||
}
|
||
|
||
# Dialogue
|
||
self._hlightDialog = False
|
||
self._rxAltDialog = REGEX_PATTERNS.altDialogStyle
|
||
self._dialogParser = DialogParser()
|
||
self._dialogParser.initParser()
|
||
|
||
##
|
||
# Properties
|
||
##
|
||
|
||
@property
|
||
def textStats(self) -> dict[str, int]:
|
||
"""The collected stats about the text."""
|
||
return self._counts
|
||
|
||
@property
|
||
def textOutline(self) -> dict[str, str]:
|
||
"""The generated outline of the text."""
|
||
return self._outline
|
||
|
||
@property
|
||
def errData(self) -> list[str]:
|
||
"""The error data."""
|
||
return self._errData
|
||
|
||
##
|
||
# Setters
|
||
##
|
||
|
||
def setLanguage(self, language: str | None) -> None:
|
||
"""Set language for the document."""
|
||
if language:
|
||
self._dLocale = QLocale(language)
|
||
|
||
def setTheme(self, theme: TextDocumentTheme) -> None:
|
||
"""Set the document colour theme."""
|
||
self._theme = theme
|
||
|
||
def setPartitionFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the partition format pattern."""
|
||
self._fmtPart = hFormat.strip()
|
||
self._hidePart = hide
|
||
|
||
def setChapterFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the chapter format pattern."""
|
||
self._fmtChapter = hFormat.strip()
|
||
self._hideChapter = hide
|
||
|
||
def setUnNumberedFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the unnumbered format pattern."""
|
||
self._fmtUnNum = hFormat.strip()
|
||
self._hideUnNum = hide
|
||
|
||
def setSceneFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the scene format pattern and hidden status."""
|
||
self._fmtScene = hFormat.strip()
|
||
self._hideScene = hide
|
||
|
||
def setHardSceneFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the hard scene format pattern and hidden status."""
|
||
self._fmtHScene = hFormat.strip()
|
||
self._hideHScene = hide
|
||
|
||
def setSectionFormat(self, hFormat: str, hide: bool = False) -> None:
|
||
"""Set the section format pattern and hidden status."""
|
||
self._fmtSection = hFormat.strip()
|
||
self._hideSection = hide
|
||
|
||
def setTitleStyle(self, center: bool, pageBreak: bool) -> None:
|
||
"""Set the title heading style."""
|
||
self._titleStyle = BlockFmt.CENTRE if center else BlockFmt.NONE
|
||
self._titleStyle |= BlockFmt.PBB if pageBreak else BlockFmt.NONE
|
||
|
||
def setPartitionStyle(self, center: bool, pageBreak: bool) -> None:
|
||
"""Set the partition heading style."""
|
||
self._partStyle = BlockFmt.CENTRE if center else BlockFmt.NONE
|
||
self._partStyle |= BlockFmt.PBB if pageBreak else BlockFmt.NONE
|
||
|
||
def setChapterStyle(self, center: bool, pageBreak: bool) -> None:
|
||
"""Set the chapter heading style."""
|
||
self._chapterStyle = BlockFmt.CENTRE if center else BlockFmt.NONE
|
||
self._chapterStyle |= BlockFmt.PBB if pageBreak else BlockFmt.NONE
|
||
|
||
def setSceneStyle(self, center: bool, pageBreak: bool) -> None:
|
||
"""Set the scene heading style."""
|
||
self._sceneStyle = BlockFmt.CENTRE if center else BlockFmt.NONE
|
||
self._sceneStyle |= BlockFmt.PBB if pageBreak else BlockFmt.NONE
|
||
|
||
def setTextFont(self, font: QFont) -> None:
|
||
"""Set the build font."""
|
||
self._textFont = fontMatcher(font)
|
||
|
||
def setLineHeight(self, height: float) -> None:
|
||
"""Set the line height between 0.5 and 5.0."""
|
||
self._lineHeight = min(max(float(height), 0.5), 5.0)
|
||
|
||
def setHeadingStyles(self, color: bool, scale: bool, bold: bool) -> None:
|
||
"""Set text style for headings."""
|
||
self._colorHeads = color
|
||
self._scaleHeads = scale
|
||
self._boldHeads = bold
|
||
|
||
def setBlockIndent(self, indent: float) -> None:
|
||
"""Set the block indent between 0.0 and 10.0."""
|
||
self._blockIndent = min(max(float(indent), 0.0), 10.0)
|
||
|
||
def setFirstLineIndent(self, state: bool, indent: float, first: bool) -> None:
|
||
"""Set first line indent and whether to also indent first
|
||
paragraph after a heading.
|
||
"""
|
||
self._firstIndent = state
|
||
self._firstWidth = indent
|
||
self._indentFirst = first
|
||
|
||
def setJustify(self, state: bool) -> None:
|
||
"""Enable or disable text justification."""
|
||
self._doJustify = state
|
||
|
||
def setDialogHighlight(self, state: bool) -> None:
|
||
"""Enable or disable dialogue highlighting."""
|
||
self._hlightDialog = state
|
||
|
||
def setTitleMargins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower title margin."""
|
||
self._marginTitle = (float(upper), float(lower))
|
||
|
||
def setHead1Margins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower heading 1 margin."""
|
||
self._marginHead1 = (float(upper), float(lower))
|
||
|
||
def setHead2Margins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower heading 2 margin."""
|
||
self._marginHead2 = (float(upper), float(lower))
|
||
|
||
def setHead3Margins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower heading 3 margin."""
|
||
self._marginHead3 = (float(upper), float(lower))
|
||
|
||
def setHead4Margins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower heading 4 margin."""
|
||
self._marginHead4 = (float(upper), float(lower))
|
||
|
||
def setTextMargins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower text margin."""
|
||
self._marginText = (float(upper), float(lower))
|
||
|
||
def setMetaMargins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower meta text margin."""
|
||
self._marginMeta = (float(upper), float(lower))
|
||
|
||
def setSeparatorMargins(self, upper: float, lower: float) -> None:
|
||
"""Set the upper and lower meta text margin."""
|
||
self._marginSep = (float(upper), float(lower))
|
||
|
||
def setLinkHeadings(self, state: bool) -> None:
|
||
"""Enable or disable adding an anchor before headings."""
|
||
self._linkHeadings = state
|
||
|
||
def setBodyText(self, state: bool) -> None:
|
||
"""Include body text in build."""
|
||
self._doBodyText = state
|
||
|
||
def setCommentType(self, comment: nwComment, state: bool) -> None:
|
||
"""Toggle the inclusion og certain comment types."""
|
||
if state:
|
||
self._doComments.add(comment)
|
||
else:
|
||
self._doComments.discard(comment)
|
||
|
||
def setKeywords(self, state: bool) -> None:
|
||
"""Include keywords in build."""
|
||
self._doKeywords = state
|
||
|
||
def setIgnoredKeywords(self, keywords: str) -> None:
|
||
"""Comma separated string of keywords to ignore."""
|
||
self._skipKeywords = set(x.lower().strip() for x in keywords.split(","))
|
||
|
||
def setKeepLineBreaks(self, state: bool) -> None:
|
||
"""Keep line breaks in paragraphs."""
|
||
self._keepBreaks = state
|
||
|
||
##
|
||
# Class Methods
|
||
##
|
||
|
||
@abstractmethod
|
||
def doConvert(self) -> None:
|
||
raise NotImplementedError
|
||
|
||
@abstractmethod
|
||
def closeDocument(self) -> None:
|
||
raise NotImplementedError
|
||
|
||
@abstractmethod
|
||
def saveDocument(self, path: Path) -> None:
|
||
raise NotImplementedError
|
||
|
||
def initDocument(self) -> None:
|
||
"""Initialise data after settings."""
|
||
self._classes["modifier"] = self._theme.modifier
|
||
self._classes["note"] = self._theme.note
|
||
self._classes["comment"] = self._theme.comment
|
||
self._classes["dialog"] = self._theme.dialog
|
||
self._classes["altdialog"] = self._theme.altdialog
|
||
self._classes["tag"] = self._theme.tag
|
||
self._classes["keyword"] = self._theme.keyword
|
||
self._classes["optional"] = self._theme.optional
|
||
|
||
def setBreakNext(self) -> None:
|
||
"""Set a page break for next block."""
|
||
self._breakNext = True
|
||
|
||
def addRootHeading(self, tHandle: str) -> None:
|
||
"""Add a heading at the start of a new root folder."""
|
||
self._text = ""
|
||
self._handle = None
|
||
|
||
if (item := self._project.tree[tHandle]) and item.isRootType():
|
||
self._handle = tHandle
|
||
style = BlockFmt.CENTRE
|
||
if self._isFirst:
|
||
self._isFirst = False
|
||
else:
|
||
style |= BlockFmt.PBB
|
||
|
||
title = item.itemName
|
||
if not item.isNovelLike():
|
||
notes = self._localLookup("Notes")
|
||
title = f"{notes}: {title}"
|
||
|
||
self._blocks = [(
|
||
BlockTyp.TITLE, f"{self._handle}:T0001", title, [], style
|
||
)]
|
||
if self._keepRaw:
|
||
self._raw.append(f"#! {title}\n\n")
|
||
|
||
def setText(self, tHandle: str, text: str | None = None) -> None:
|
||
"""Set the text for the tokenizer from a handle. If text is not
|
||
set, it's is loaded from the file.
|
||
"""
|
||
self._text = ""
|
||
self._handle = None
|
||
if nwItem := self._project.tree[tHandle]:
|
||
self._text = text or self._project.storage.getDocumentText(tHandle)
|
||
self._handle = tHandle
|
||
self._isNovel = nwItem.itemLayout == nwItemLayout.DOCUMENT
|
||
|
||
def doPreProcessing(self) -> None:
|
||
"""Run pre-processing jobs before the text is tokenized."""
|
||
# Process the user's auto-replace dictionary
|
||
if entry := self._project.data.autoReplace:
|
||
replace = {f"<{k}>": v for k, v in entry.items()}
|
||
rxRep = re.compile("|".join([re.escape(k) for k in replace]), flags=re.DOTALL)
|
||
self._text = rxRep.sub(lambda x: replace[x.group(0)], self._text)
|
||
|
||
def tokenizeText(self) -> None:
|
||
"""Scan the text for either lines starting with specific
|
||
characters that indicate headings, comments, commands etc, or
|
||
just contain 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 blocks list.
|
||
|
||
The format of the blocs list is an entry with a five-tuple for
|
||
each line in the file. The tuple is as follows:
|
||
1: The type of the block, BlockType.*
|
||
2: The heading number under which the text is placed
|
||
3: The text content of the block, without leading tags
|
||
4: The internal formatting map of the text, TxtFmt.*
|
||
5: The formats of the block, BlockFmt.*
|
||
"""
|
||
if self._keepRaw:
|
||
self._raw.append(f"{self._text.rstrip()}\n\n")
|
||
if self._noTokens:
|
||
return
|
||
if self._isNovel:
|
||
self._hFormatter.setHandle(self._handle)
|
||
|
||
# Cache Flags
|
||
isNovel = self._isNovel
|
||
doJustify = self._doJustify
|
||
keepBreaks = self._keepBreaks
|
||
indentFirst = self._indentFirst
|
||
firstIndent = self._firstIndent
|
||
|
||
# Replace all instances of [br] with a placeholder character
|
||
text = REGEX_PATTERNS.lineBreak.sub(nwUnicode.U_NAC2, self._text)
|
||
|
||
# Translation Maps
|
||
transMapA = str.maketrans({
|
||
nwUnicode.U_NAC2: "", # Used when [br] is ignored
|
||
nwUnicode.U_MAPOS: nwUnicode.U_RSQUO,
|
||
nwUnicode.U_HBAR: nwUnicode.U_EMDASH,
|
||
})
|
||
transMapB = str.maketrans({
|
||
nwUnicode.U_NAC2: "\n", # Used when [br] is not ignored
|
||
nwUnicode.U_MAPOS: nwUnicode.U_RSQUO,
|
||
nwUnicode.U_HBAR: nwUnicode.U_EMDASH,
|
||
})
|
||
|
||
nHead = 0
|
||
tHandle = self._handle or ""
|
||
tBlocks: list[T_Block] = [B_EMPTY]
|
||
for bLine in text.splitlines():
|
||
aLine = bLine.translate(transMapA)
|
||
sLine = aLine.strip().lower()
|
||
|
||
# Check for blank lines
|
||
if not sLine:
|
||
tBlocks.append(B_EMPTY)
|
||
continue
|
||
|
||
if self._breakNext:
|
||
tStyle = BlockFmt.PBB
|
||
self._breakNext = False
|
||
else:
|
||
tStyle = BlockFmt.NONE
|
||
|
||
# Check Line Format
|
||
# =================
|
||
|
||
if aLine.startswith("["):
|
||
# Special Formats
|
||
# ===============
|
||
# Parse special formatting line. This must be a separate if
|
||
# statement, as it may not reach a continue statement and must
|
||
# therefore proceed to check other formats.
|
||
|
||
if sLine in ("[newpage]", "[new page]"):
|
||
self._breakNext = True
|
||
continue
|
||
|
||
if sLine == "[vspace]":
|
||
tBlocks.append(
|
||
(BlockTyp.SKIP, "", "", [], tStyle)
|
||
)
|
||
continue
|
||
|
||
if sLine.startswith("[vspace:") and sLine.endswith("]"):
|
||
nSkip = checkInt(sLine[8:-1], 0)
|
||
if nSkip >= 1:
|
||
tBlocks.append(
|
||
(BlockTyp.SKIP, "", "", [], tStyle)
|
||
)
|
||
if nSkip > 1:
|
||
tBlocks += (nSkip - 1) * [
|
||
(BlockTyp.SKIP, "", "", [], BlockFmt.NONE)
|
||
]
|
||
continue
|
||
|
||
if aLine.startswith("%"):
|
||
# Comments
|
||
# ========
|
||
# All style comments are processed and the exact type exact
|
||
# style extracted. Ignored comments on the '%~' format are
|
||
# skipped completely.
|
||
if aLine.startswith("%~"):
|
||
continue
|
||
|
||
cStyle, cKey, cText, _, _ = processComment(aLine)
|
||
if cStyle not in self._doComments:
|
||
continue
|
||
|
||
if doJustify and not tStyle & BlockFmt.ALIGNED:
|
||
tStyle |= BlockFmt.JUSTIFY
|
||
|
||
if cStyle in (
|
||
nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN,
|
||
nwComment.STORY, nwComment.NOTE,
|
||
):
|
||
bStyle = COMMENT_STYLE[cStyle]
|
||
tLine, tFmt = self._formatComment(bStyle, cKey, cText)
|
||
tBlocks.append((
|
||
COMMENT_TYPE[cStyle], "", tLine, tFmt, tStyle
|
||
))
|
||
|
||
elif cStyle == nwComment.FOOTNOTE:
|
||
tLine, tFmt = self._extractFormats(cText, skip=TextFmt.FNOTE)
|
||
self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt)
|
||
|
||
elif aLine.startswith("@"):
|
||
# Keywords
|
||
# ========
|
||
# Only valid keyword lines are parsed, and any ignored keywords
|
||
# are automatically skipped.
|
||
|
||
if self._doKeywords:
|
||
tTag, tLine, tFmt = self._formatMeta(aLine)
|
||
if tLine:
|
||
tBlocks.append((
|
||
BlockTyp.KEYWORD, tTag[1:], tLine, tFmt, tStyle
|
||
))
|
||
|
||
elif aLine.startswith(("# ", "#! ")):
|
||
# Title or Partition Headings
|
||
# ===========================
|
||
# Main titles are allowed in any document, and they are always
|
||
# centred and start on a new page. For novel documents, we also
|
||
# reset all counters when such a title is encountered.
|
||
# Partition headings are only formatted in novel documents, and
|
||
# otherwise unchanged. Scene separators are disabled
|
||
# immediately after partitions, and scene numbers are reset.
|
||
isPlain = aLine.startswith("# ")
|
||
|
||
nHead += 1
|
||
tText = aLine[2:].strip()
|
||
tType = BlockTyp.HEAD1 if isPlain else BlockTyp.TITLE
|
||
sHide = self._hidePart if isPlain else False
|
||
if not (isPlain or (isNovel and sHide)):
|
||
tStyle |= self._titleStyle
|
||
if isNovel:
|
||
tType = BlockTyp.PART if isPlain else BlockTyp.TITLE
|
||
if sHide:
|
||
tText = ""
|
||
tType = BlockTyp.EMPTY
|
||
elif isPlain:
|
||
tText = self._hFormatter.apply(self._fmtPart, tText, nHead)
|
||
tStyle |= self._partStyle
|
||
if isPlain:
|
||
self._hFormatter.resetScene()
|
||
else:
|
||
self._hFormatter.resetAll()
|
||
self._noSep = True
|
||
|
||
tBlocks.append((
|
||
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
|
||
))
|
||
|
||
elif aLine.startswith(("## ", "##! ")):
|
||
# (Unnumbered) Chapter Headings
|
||
# =============================
|
||
# Chapter headings are only formatted in novel documents, and
|
||
# otherwise unchanged. Chapter numbers are bumped before the
|
||
# heading is formatted. Scene separators are disabled
|
||
# immediately after chapter headings, and scene numbers are
|
||
# reset. Unnumbered chapters are only meaningful in Novel docs,
|
||
# so if we're in a note, we keep them as level 2 headings.
|
||
isPlain = aLine.startswith("## ")
|
||
|
||
nHead += 1
|
||
tText = aLine[3:].strip()
|
||
tType = BlockTyp.HEAD2
|
||
sHide = self._hideChapter if isPlain else self._hideUnNum
|
||
tFormat = self._fmtChapter if isPlain else self._fmtUnNum
|
||
if isNovel:
|
||
tType = BlockTyp.HEAD1 # Promote
|
||
if isPlain:
|
||
self._hFormatter.incChapter()
|
||
if sHide:
|
||
tText = ""
|
||
tType = BlockTyp.EMPTY
|
||
else:
|
||
tText = self._hFormatter.apply(tFormat, tText, nHead)
|
||
tStyle |= self._chapterStyle
|
||
self._hFormatter.resetScene()
|
||
self._noSep = True
|
||
|
||
tBlocks.append((
|
||
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
|
||
))
|
||
|
||
elif aLine.startswith(("### ", "###! ")):
|
||
# (Alternative) Scene Headings
|
||
# ============================
|
||
# Scene headings in novel documents are treated as centred
|
||
# separators if the formatting does not change the text. If the
|
||
# format is empty, the scene can be hidden or a blank paragraph
|
||
# (skip). When the scene title has static text or no text, it
|
||
# is always ignored if the noSep flag is set. This prevents
|
||
# separators immediately after other titles. Scene numbers are
|
||
# always incremented before formatting. For notes, the heading
|
||
# is unchanged.
|
||
isPlain = aLine.startswith("### ")
|
||
|
||
nHead += 1
|
||
tText = aLine[4:].strip()
|
||
tType = BlockTyp.HEAD3
|
||
sHide = self._hideScene if isPlain else self._hideHScene
|
||
tFormat = self._fmtScene if isPlain else self._fmtHScene
|
||
if isNovel:
|
||
tType = BlockTyp.HEAD2 # Promote
|
||
self._hFormatter.incScene()
|
||
if sHide:
|
||
tText = ""
|
||
tType = BlockTyp.EMPTY
|
||
else:
|
||
tText = self._hFormatter.apply(tFormat, tText, nHead)
|
||
tStyle |= self._sceneStyle
|
||
if tText == "": # Empty Format
|
||
tType = BlockTyp.EMPTY if self._noSep else BlockTyp.SKIP
|
||
elif tText == tFormat: # Static Format
|
||
tText = "" if self._noSep else tText
|
||
tType = BlockTyp.EMPTY if self._noSep else BlockTyp.SEP
|
||
tStyle |= BlockFmt.NONE if self._noSep else BlockFmt.CENTRE
|
||
self._noSep = False
|
||
|
||
tBlocks.append((
|
||
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
|
||
))
|
||
|
||
elif aLine.startswith("#### "):
|
||
# Section Headings
|
||
# =================
|
||
# Section headings in novel docs are treated as centred
|
||
# separators if the formatting does not change the text. If the
|
||
# format is empty, the section can be hidden or a blank
|
||
# paragraph (skip). For notes, the heading is unchanged.
|
||
|
||
nHead += 1
|
||
tText = aLine[5:].strip()
|
||
tType = BlockTyp.HEAD4
|
||
if isNovel:
|
||
tType = BlockTyp.HEAD3 # Promote
|
||
if self._hideSection:
|
||
tText = ""
|
||
tType = BlockTyp.EMPTY
|
||
else:
|
||
tText = self._hFormatter.apply(self._fmtSection, tText, nHead)
|
||
if tText == "": # Empty Format
|
||
tType = BlockTyp.SKIP
|
||
elif tText == self._fmtSection: # Static Format
|
||
tType = BlockTyp.SEP
|
||
tStyle |= BlockFmt.CENTRE
|
||
|
||
tBlocks.append((
|
||
tType, f"{tHandle}:T{nHead:04d}", tText, [], tStyle
|
||
))
|
||
|
||
else:
|
||
# Text Lines
|
||
# ==========
|
||
# Anything remaining at this point is body text. If body text
|
||
# is not disabled, we proceed to process text formatting.
|
||
if not self._doBodyText:
|
||
# Skip all body text
|
||
continue
|
||
|
||
# Check Alignment and Indentation
|
||
alnLeft = False
|
||
alnRight = False
|
||
indLeft = False
|
||
indRight = False
|
||
if bLine.startswith(">>"):
|
||
alnRight = True
|
||
bLine = bLine[2:].lstrip(" ")
|
||
elif bLine.startswith(">"):
|
||
indLeft = True
|
||
bLine = bLine[1:].lstrip(" ")
|
||
|
||
if bLine.endswith("<<"):
|
||
alnLeft = True
|
||
bLine = bLine[:-2].rstrip(" ")
|
||
elif bLine.endswith("<"):
|
||
indRight = True
|
||
bLine = bLine[:-1].rstrip(" ")
|
||
|
||
if alnLeft and alnRight:
|
||
tStyle |= BlockFmt.CENTRE
|
||
elif alnLeft:
|
||
tStyle |= BlockFmt.LEFT
|
||
elif alnRight:
|
||
tStyle |= BlockFmt.RIGHT
|
||
|
||
if indLeft:
|
||
tStyle |= BlockFmt.IND_L
|
||
if indRight:
|
||
tStyle |= BlockFmt.IND_R
|
||
|
||
# Process formats
|
||
tLine, tFmt = self._extractFormats(bLine, hDialog=isNovel)
|
||
tBlocks.append((
|
||
BlockTyp.TEXT, "", tLine, tFmt, tStyle
|
||
))
|
||
|
||
# If we have content, turn off the first page flag
|
||
if self._isFirst and len(tBlocks) > 1:
|
||
self._isFirst = False # First document has been processed
|
||
|
||
# Make sure the blocks array doesn't start with a page break
|
||
# on the very first block, adding a blank first page.
|
||
for n, cBlock in enumerate(tBlocks):
|
||
if cBlock[0] != BlockTyp.EMPTY:
|
||
if cBlock[4] & BlockFmt.PBB:
|
||
tBlocks[n] = (
|
||
cBlock[0], cBlock[1], cBlock[2], cBlock[3], cBlock[4] & ~BlockFmt.PBB
|
||
)
|
||
break
|
||
|
||
# Always add an empty line at the end of the file
|
||
tBlocks.append(B_EMPTY)
|
||
|
||
# Second Pass
|
||
# ===========
|
||
# This second pass strips away consecutive blank lines, and
|
||
# combines consecutive text lines into the same paragraph.
|
||
# It also ensures that there isn't paragraph spacing between
|
||
# meta data lines for formats that have spacing.
|
||
|
||
lineSep = "\n" if keepBreaks else " "
|
||
|
||
pLines: list[T_Block] = []
|
||
sBlocks: list[T_Block] = []
|
||
for n, cBlock in enumerate(tBlocks[1:-1], 1):
|
||
|
||
pBlock = tBlocks[n-1] # Look behind
|
||
nBlock = tBlocks[n+1] # Look ahead
|
||
|
||
if cBlock[0] in SKIP_INDENT and not indentFirst:
|
||
# Unless the indentFirst flag is set, we set up the next
|
||
# paragraph to not be indented if we see a block of a
|
||
# specific type
|
||
self._noIndent = True
|
||
|
||
if cBlock[0] == BlockTyp.EMPTY:
|
||
# We don't need to keep the empty lines after this pass
|
||
pass
|
||
|
||
elif cBlock[0] in (BlockTyp.KEYWORD, BlockTyp.NOTE):
|
||
# Adjust margins for lines in repeated meta blocks
|
||
aStyle = cBlock[4]
|
||
if pBlock[0] == cBlock[0]:
|
||
aStyle |= BlockFmt.Z_TOP
|
||
if nBlock[0] == cBlock[0]:
|
||
aStyle |= BlockFmt.Z_BTM
|
||
sBlocks.append((
|
||
cBlock[0], cBlock[1], cBlock[2], cBlock[3], aStyle
|
||
))
|
||
|
||
elif cBlock[0] == BlockTyp.TEXT:
|
||
# Combine lines from the same paragraph
|
||
pLines.append(cBlock)
|
||
|
||
if nBlock[0] != BlockTyp.TEXT:
|
||
# Next block is not text, so we add the buffer to blocks
|
||
nLines = len(pLines)
|
||
tFmt: T_Formats = []
|
||
pTxt = ""
|
||
cStyle = BlockFmt.NONE
|
||
|
||
if nLines == 1:
|
||
# The paragraph contains a single line
|
||
tFmt = pLines[0][3]
|
||
pTxt = pLines[0][2].translate(transMapB)
|
||
cStyle = pLines[0][4]
|
||
|
||
elif nLines > 1:
|
||
# The paragraph contains multiple lines, so we need to
|
||
# join them according to the line break policy, and
|
||
# recompute all the formatting markers
|
||
tTxt = ""
|
||
for aBlock in pLines:
|
||
tLen = len(tTxt)
|
||
tTxt += f"{aBlock[2]}{lineSep}"
|
||
tFmt.extend((p+tLen, fmt, key) for p, fmt, key in aBlock[3])
|
||
cStyle |= aBlock[4]
|
||
|
||
pTxt = tTxt[:-1].translate(transMapB)
|
||
|
||
if nLines:
|
||
isAligned = cStyle & BlockFmt.ALIGNED
|
||
if firstIndent and not (self._noIndent or isAligned):
|
||
# If paragraph indentation is enabled, not temporarily
|
||
# turned off, and the block is not aligned, we add the
|
||
# text indentation flag
|
||
cStyle |= BlockFmt.IND_T
|
||
|
||
if doJustify and not isAligned:
|
||
cStyle |= BlockFmt.JUSTIFY
|
||
|
||
sBlocks.append((
|
||
BlockTyp.TEXT, pLines[0][1], pTxt, tFmt, cStyle
|
||
))
|
||
|
||
# Reset buffer and make sure text indent is on for next pass
|
||
pLines = []
|
||
self._noIndent = False
|
||
|
||
else:
|
||
sBlocks.append(cBlock)
|
||
|
||
self._blocks = sBlocks
|
||
|
||
return
|
||
|
||
def buildOutline(self) -> None:
|
||
"""Build an outline of the text up to level 3 headings."""
|
||
isNovel = self._isNovel
|
||
for tType, tKey, tText, _, _ in self._blocks:
|
||
if tType == BlockTyp.TITLE:
|
||
prefix = "TT"
|
||
elif tType == BlockTyp.PART:
|
||
prefix = "PT"
|
||
elif tType == BlockTyp.HEAD1:
|
||
prefix = "CH" if isNovel else "H1"
|
||
elif tType == BlockTyp.HEAD2:
|
||
prefix = "SC" if isNovel else "H2"
|
||
elif tType == BlockTyp.HEAD3 and not isNovel:
|
||
prefix = "H3"
|
||
else:
|
||
continue
|
||
|
||
text = tText.replace(nwHeadFmt.BR, " ").replace("&", "&")
|
||
self._outline[tKey] = f"{prefix}|{text}"
|
||
|
||
def countStats(self) -> None:
|
||
"""Count stats on the tokenized text."""
|
||
titleCount = self._counts.get(nwStats.TITLES, 0)
|
||
paragraphCount = self._counts.get(nwStats.PARAGRAPHS, 0)
|
||
|
||
allWords = self._counts.get(nwStats.WORDS, 0)
|
||
textWords = self._counts.get(nwStats.WORDS_TEXT, 0)
|
||
titleWords = self._counts.get(nwStats.WORDS_TITLE, 0)
|
||
|
||
allChars = self._counts.get(nwStats.CHARS, 0)
|
||
textChars = self._counts.get(nwStats.CHARS_TEXT, 0)
|
||
titleChars = self._counts.get(nwStats.CHARS_TITLE, 0)
|
||
|
||
allWordChars = self._counts.get(nwStats.WCHARS_ALL, 0)
|
||
textWordChars = self._counts.get(nwStats.WCHARS_TEXT, 0)
|
||
titleWordChars = self._counts.get(nwStats.WCHARS_TITLE, 0)
|
||
|
||
for tType, _, tText, _, _ in self._blocks:
|
||
tText = tText.replace(nwUnicode.U_ENDASH, " ")
|
||
tText = tText.replace(nwUnicode.U_EMDASH, " ")
|
||
|
||
tWords = tText.split()
|
||
nWords = len(tWords)
|
||
nChars = len(tText)
|
||
nWChars = len("".join(tWords))
|
||
|
||
if tType == BlockTyp.TEXT:
|
||
tPWords = tText.split()
|
||
nPWords = len(tPWords)
|
||
nPChars = len(tText)
|
||
nPWChars = len("".join(tPWords))
|
||
|
||
paragraphCount += 1
|
||
allWords += nPWords
|
||
textWords += nPWords
|
||
allChars += nPChars
|
||
textChars += nPChars
|
||
allWordChars += nPWChars
|
||
textWordChars += nPWChars
|
||
|
||
elif tType in HEADING_BLOCKS:
|
||
titleCount += 1
|
||
allWords += nWords
|
||
titleWords += nWords
|
||
allChars += nChars
|
||
allWordChars += nWChars
|
||
titleChars += nChars
|
||
titleWordChars += nWChars
|
||
|
||
elif tType == BlockTyp.SEP:
|
||
allWords += nWords
|
||
allChars += nChars
|
||
allWordChars += nWChars
|
||
|
||
elif tType in META_BLOCKS:
|
||
words = tText.split()
|
||
allWords += len(words)
|
||
allChars += len(tText)
|
||
allWordChars += len("".join(words))
|
||
|
||
self._counts[nwStats.TITLES] = titleCount
|
||
self._counts[nwStats.PARAGRAPHS] = paragraphCount
|
||
|
||
self._counts[nwStats.WORDS] = allWords
|
||
self._counts[nwStats.WORDS_TEXT] = textWords
|
||
self._counts[nwStats.WORDS_TITLE] = titleWords
|
||
|
||
self._counts[nwStats.CHARS] = allChars
|
||
self._counts[nwStats.CHARS_TEXT] = textChars
|
||
self._counts[nwStats.CHARS_TITLE] = titleChars
|
||
|
||
self._counts[nwStats.WCHARS_ALL] = allWordChars
|
||
self._counts[nwStats.WCHARS_TEXT] = textWordChars
|
||
self._counts[nwStats.WCHARS_TITLE] = titleWordChars
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _formatInt(self, value: int) -> str:
|
||
"""Return a localised integer."""
|
||
return self._dLocale.toString(value)
|
||
|
||
def _formatComment(self, style: ComStyle, key: str, text: str) -> tuple[str, T_Formats]:
|
||
"""Apply formatting to comments and notes."""
|
||
rFmt = []
|
||
tTxt, tFmt = self._extractFormats(text)
|
||
tFmt.insert(0, (0, TextFmt.COL_B, style.textClass))
|
||
tFmt.append((len(tTxt), TextFmt.COL_E, ""))
|
||
term = f" ({key.title()})" if key else ""
|
||
if label := f"{self._localLookup(style.label)}{term}".strip():
|
||
shift = len(label) + 2
|
||
tTxt = f"{label}: {tTxt}"
|
||
rFmt = [(0, TextFmt.B_B, ""), (shift - 1, TextFmt.B_E, "")]
|
||
if style.labelClass:
|
||
rFmt.insert(1, (0, TextFmt.COL_B, style.labelClass))
|
||
rFmt.insert(2, (shift - 1, TextFmt.COL_E, ""))
|
||
rFmt.extend((p + shift, f, d) for p, f, d in tFmt)
|
||
return tTxt, rFmt
|
||
|
||
def _formatMeta(self, text: str) -> tuple[str, str, T_Formats]:
|
||
"""Apply formatting to a meta data line."""
|
||
tag = ""
|
||
txt = []
|
||
fmt = []
|
||
valid, bits, _ = self._project.index.scanThis(text)
|
||
if valid and bits and bits[0] in nwLabels.KEY_NAME and bits[0] not in self._skipKeywords:
|
||
tag = bits[0]
|
||
pos = 0
|
||
lbl = f"{self._localLookup(nwLabels.KEY_NAME[tag])}:"
|
||
end = len(lbl)
|
||
fmt = [
|
||
(pos, TextFmt.B_B, ""),
|
||
(pos, TextFmt.COL_B, "keyword"),
|
||
(end, TextFmt.COL_E, ""),
|
||
(end, TextFmt.B_E, ""),
|
||
]
|
||
txt = [lbl, " "]
|
||
pos = end + 1
|
||
|
||
if (num := len(bits)) > 1:
|
||
if bits[0] == nwKeyWords.TAG_KEY:
|
||
one, two = self._project.index.parseValue(bits[1])
|
||
end = pos + len(one)
|
||
fmt.append((pos, TextFmt.COL_B, "tag"))
|
||
fmt.append((pos, TextFmt.ANM_B, f"tag_{one}".lower()))
|
||
fmt.append((end, TextFmt.ANM_E, ""))
|
||
fmt.append((end, TextFmt.COL_E, ""))
|
||
txt.append(one)
|
||
pos = end
|
||
if two:
|
||
txt.append(" | ")
|
||
pos += 3
|
||
end = pos + len(two)
|
||
fmt.append((pos, TextFmt.COL_B, "optional"))
|
||
fmt.append((end, TextFmt.COL_E, ""))
|
||
txt.append(two)
|
||
pos = end
|
||
else:
|
||
for n, bit in enumerate(bits[1:], 2):
|
||
end = pos + len(bit)
|
||
fmt.append((pos, TextFmt.COL_B, "tag"))
|
||
fmt.append((pos, TextFmt.ARF_B, f"#tag_{bit}".lower()))
|
||
fmt.append((end, TextFmt.ARF_E, ""))
|
||
fmt.append((end, TextFmt.COL_E, ""))
|
||
txt.append(bit)
|
||
pos = end
|
||
if n < num:
|
||
txt.append(", ")
|
||
pos += 2
|
||
|
||
return tag, "".join(txt), fmt
|
||
|
||
def _extractFormats(
|
||
self, text: str, skip: int = 0, hDialog: bool = False
|
||
) -> tuple[str, T_Formats]:
|
||
"""Extract format markers from a text paragraph. In order to
|
||
also process dialogue highlighting, the hDialog flag must be set
|
||
to True. See issues #2011 and #2013.
|
||
"""
|
||
temp: list[tuple[int, int, int, str]] = []
|
||
|
||
# Match Markdown
|
||
for regEx, fmts in self._rxMarkdown:
|
||
for res in regEx.finditer(text):
|
||
temp.extend(
|
||
(res.start(n), res.end(n), fmt, "")
|
||
for n, fmt in enumerate(fmts) if fmt > 0
|
||
)
|
||
|
||
# Match URLs
|
||
for res in REGEX_PATTERNS.url.finditer(text):
|
||
temp.append((res.start(0), 0, TextFmt.HRF_B, res.group(0)))
|
||
temp.append((res.end(0), 0, TextFmt.HRF_E, ""))
|
||
|
||
# Match Shortcodes
|
||
temp.extend(
|
||
(res.start(1), res.end(1), self._shortCodeFmt.get(res.group(1).lower(), 0), "")
|
||
for res in REGEX_PATTERNS.shortcodePlain.finditer(text)
|
||
)
|
||
|
||
# Match Shortcode w/Values
|
||
tHandle = self._handle or ""
|
||
for res in REGEX_PATTERNS.shortcodeValue.finditer(text):
|
||
kind = self._shortCodeVals.get(res.group(1).lower(), 0)
|
||
temp.append((
|
||
res.start(0), res.end(0),
|
||
TextFmt.STRIP if kind == skip else kind,
|
||
f"{tHandle}:{res.group(2)}",
|
||
))
|
||
|
||
# Match Dialogue
|
||
if self._hlightDialog and hDialog:
|
||
if self._dialogParser.enabled:
|
||
for pos, end in self._dialogParser(text):
|
||
temp.append((pos, 0, TextFmt.COL_B, "dialog"))
|
||
temp.append((end, 0, TextFmt.COL_E, ""))
|
||
if self._rxAltDialog:
|
||
for res in self._rxAltDialog.finditer(text):
|
||
temp.append((res.start(0), 0, TextFmt.COL_B, "altdialog"))
|
||
temp.append((res.end(0), 0, TextFmt.COL_E, ""))
|
||
|
||
# Post-process text and format
|
||
result = text
|
||
formats = []
|
||
for pos, end, fmt, meta in reversed(sorted(temp, key=lambda x: x[0])):
|
||
if fmt > 0:
|
||
if end > pos:
|
||
result = result[:pos] + result[end:]
|
||
formats = [(p+pos-end if p > pos else p, f, m) for p, f, m in formats]
|
||
formats.insert(0, (pos, fmt, meta))
|
||
|
||
return result, formats
|
||
|
||
|
||
class HeadingFormatter:
|
||
"""Core: Format Text Headings.
|
||
|
||
This class holds the various chapter and scene counters and can
|
||
apply the Build Settings header format settings based on internal
|
||
counter state.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
project: NWProject,
|
||
chapter: int = 0,
|
||
scene: int = 0,
|
||
absolute: int = 0,
|
||
) -> None:
|
||
self._project = project
|
||
self._handle = None
|
||
self._chapter = chapter
|
||
self._scene = scene
|
||
self._absolute = absolute
|
||
|
||
def setHandle(self, tHandle: str | None) -> None:
|
||
"""Set the handle currently being processed."""
|
||
self._handle = tHandle
|
||
|
||
def incChapter(self) -> None:
|
||
"""Increment the chapter counter."""
|
||
self._chapter += 1
|
||
|
||
def incScene(self) -> None:
|
||
"""Increment the scene counters."""
|
||
self._scene += 1
|
||
self._absolute += 1
|
||
|
||
def resetAll(self) -> None:
|
||
"""Reset all counters."""
|
||
self._chapter = 0
|
||
self._scene = 0
|
||
self._absolute = 0
|
||
|
||
def resetScene(self) -> None:
|
||
"""Reset the chapter scene counter."""
|
||
self._scene = 0
|
||
|
||
def apply(self, hFormat: str, text: str, nHead: int) -> str:
|
||
"""Apply formatting to a specific heading."""
|
||
hFormat = hFormat.replace(nwHeadFmt.TITLE, text)
|
||
hFormat = hFormat.replace(nwHeadFmt.BR, "\n")
|
||
hFormat = hFormat.replace(nwHeadFmt.CH_NUM, str(self._chapter))
|
||
hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scene))
|
||
hFormat = hFormat.replace(nwHeadFmt.SC_ABS, str(self._absolute))
|
||
if nwHeadFmt.CH_WORD in hFormat:
|
||
chWord = self._project.localLookup(self._chapter)
|
||
hFormat = hFormat.replace(nwHeadFmt.CH_WORD, chWord)
|
||
if nwHeadFmt.CH_ROML in hFormat:
|
||
chRom = numberToRoman(self._chapter, toLower=True)
|
||
hFormat = hFormat.replace(nwHeadFmt.CH_ROML, chRom)
|
||
if nwHeadFmt.CH_ROMU in hFormat:
|
||
chRom = numberToRoman(self._chapter, toLower=False)
|
||
hFormat = hFormat.replace(nwHeadFmt.CH_ROMU, chRom)
|
||
|
||
if nwHeadFmt.CHAR_POV in hFormat or nwHeadFmt.CHAR_FOCUS in hFormat:
|
||
if self._handle and nHead > 0:
|
||
index = self._project.index
|
||
pList = index.getReferenceForHeader(self._handle, nHead, nwKeyWords.POV_KEY)
|
||
fList = index.getReferenceForHeader(self._handle, nHead, nwKeyWords.FOCUS_KEY)
|
||
pText = pList[0] if pList else nwUnicode.U_ENDASH
|
||
fText = fList[0] if fList else nwUnicode.U_ENDASH
|
||
else:
|
||
pText = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
|
||
fText = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
|
||
hFormat = hFormat.replace(nwHeadFmt.CHAR_POV, pText)
|
||
hFormat = hFormat.replace(nwHeadFmt.CHAR_FOCUS, fText)
|
||
|
||
return hFormat
|