Files
novelWriter/novelwriter/formats/tokenizer.py
T
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

1215 lines
48 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.
"""
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("&amp;", "&")
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