484 lines
18 KiB
Python
484 lines
18 KiB
Python
"""
|
||
novelWriter – QTextDocument Converter
|
||
=====================================
|
||
|
||
File History:
|
||
Created: 2024-05-21 [2.5b1] ToQTextDocument
|
||
|
||
This file is a part of novelWriter
|
||
Copyright (C) 2024 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
|
||
|
||
from typing import TYPE_CHECKING
|
||
|
||
from PyQt6.QtCore import QMarginsF, QSizeF
|
||
from PyQt6.QtGui import (
|
||
QColor, QFont, QFontDatabase, QPageLayout, QPageSize, QTextBlockFormat,
|
||
QTextCharFormat, QTextCursor, QTextDocument, QTextFrameFormat
|
||
)
|
||
from PyQt6.QtPrintSupport import QPrinter
|
||
|
||
from novelwriter import __version__
|
||
from novelwriter.constants import nwStyles, nwUnicode
|
||
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, stripEscape
|
||
from novelwriter.formats.tokenizer import HEADING_BLOCKS, META_BLOCKS, Tokenizer
|
||
from novelwriter.types import (
|
||
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
|
||
QtKeepAnchor, QtMoveAnchor, QtPageBreakAfter, QtPageBreakAuto,
|
||
QtPageBreakBefore, QtPropLineHeight, QtTransparent, QtVAlignNormal,
|
||
QtVAlignSub, QtVAlignSuper
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from pathlib import Path
|
||
|
||
from novelwriter.core.project import NWProject
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat]
|
||
|
||
|
||
def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None:
|
||
"""Insert a new block if not at the beginning of the document."""
|
||
if cursor.position() > 0:
|
||
cursor.insertBlock(bFmt)
|
||
else:
|
||
cursor.setBlockFormat(bFmt)
|
||
|
||
|
||
class ToQTextDocument(Tokenizer):
|
||
"""Core: QTextDocument Writer.
|
||
|
||
Extend the Tokenizer class to generate a QTextDocument output. This
|
||
is intended for usage in the document viewer and build tool preview.
|
||
"""
|
||
|
||
def __init__(self, project: NWProject) -> None:
|
||
super().__init__(project)
|
||
self._document = QTextDocument()
|
||
self._document.setUndoRedoEnabled(False)
|
||
self._document.setDocumentMargin(0.0)
|
||
|
||
self._usedNotes: dict[str, int] = {}
|
||
self._usedFields: list[tuple[int, str]] = []
|
||
|
||
self._init = False
|
||
self._newPage = False
|
||
self._anchors = True
|
||
|
||
self._hWeight = QFont.Weight.Bold
|
||
self._dWeight = QFont.Weight.Normal
|
||
self._dItalic = False
|
||
self._dStrike = False
|
||
self._dUnderline = False
|
||
|
||
self._dpi = 96
|
||
self._pageSize = QPageSize(QPageSize.PageSizeId.A4)
|
||
self._pageMargins = QMarginsF(20.0, 20.0, 20.0, 20.0)
|
||
|
||
##
|
||
# Properties
|
||
##
|
||
|
||
@property
|
||
def document(self) -> QTextDocument:
|
||
"""Return the document."""
|
||
return self._document
|
||
|
||
##
|
||
# Setters
|
||
##
|
||
|
||
def setPageLayout(
|
||
self, width: float, height: float, top: float, bottom: float, left: float, right: float
|
||
) -> None:
|
||
"""Set the document page size and margins in millimetres."""
|
||
self._pageSize = QPageSize(QSizeF(width, height), QPageSize.Unit.Millimeter)
|
||
self._pageMargins = QMarginsF(left, top, right, bottom)
|
||
|
||
def setShowNewPage(self, state: bool) -> None:
|
||
"""Add markers for page breaks."""
|
||
self._newPage = state
|
||
|
||
def disableAnchors(self) -> None:
|
||
"""Disable anchors for when writing to file."""
|
||
self._anchors = False
|
||
|
||
##
|
||
# Class Methods
|
||
##
|
||
|
||
def initDocument(self, pdf: bool = False) -> None:
|
||
"""Initialise all computed values of the document."""
|
||
super().initDocument()
|
||
|
||
if pdf:
|
||
family = self._textFont.family()
|
||
style = self._textFont.styleName()
|
||
self._dpi = 1200 if QFontDatabase.isScalable(family, style) else 72
|
||
|
||
self._document.setUndoRedoEnabled(False)
|
||
self._document.blockSignals(True)
|
||
self._document.clear()
|
||
self._document.setDefaultFont(self._textFont)
|
||
|
||
# Default Styles
|
||
self._dWeight = self._textFont.weight()
|
||
self._dItalic = self._textFont.italic()
|
||
self._dStrike = self._textFont.strikeOut()
|
||
self._dUnderline = self._textFont.underline()
|
||
|
||
# Header Weight
|
||
self._hWeight = QFont.Weight.Bold if self._boldHeads else self._dWeight
|
||
|
||
# Scaled Sizes
|
||
# ============
|
||
|
||
fPt = self._textFont.pointSizeF()
|
||
fPx = fPt*96.0/72.0 # 1 em in pixels
|
||
mPx = fPx * self._dpi/96.0
|
||
|
||
self._mHead = {
|
||
BlockTyp.TITLE: (fPx * self._marginTitle[0], fPx * self._marginTitle[1]),
|
||
BlockTyp.PART: (fPx * self._marginTitle[0], fPx * self._marginTitle[1]),
|
||
BlockTyp.HEAD1: (fPx * self._marginHead1[0], fPx * self._marginHead1[1]),
|
||
BlockTyp.HEAD2: (fPx * self._marginHead2[0], fPx * self._marginHead2[1]),
|
||
BlockTyp.HEAD3: (fPx * self._marginHead3[0], fPx * self._marginHead3[1]),
|
||
BlockTyp.HEAD4: (fPx * self._marginHead4[0], fPx * self._marginHead4[1]),
|
||
}
|
||
|
||
hScale = self._scaleHeads
|
||
self._sHead = {
|
||
BlockTyp.TITLE: (nwStyles.H_SIZES.get(0, 1.0) * fPt) if hScale else fPt,
|
||
BlockTyp.PART: (nwStyles.H_SIZES.get(0, 1.0) * fPt) if hScale else fPt,
|
||
BlockTyp.HEAD1: (nwStyles.H_SIZES.get(1, 1.0) * fPt) if hScale else fPt,
|
||
BlockTyp.HEAD2: (nwStyles.H_SIZES.get(2, 1.0) * fPt) if hScale else fPt,
|
||
BlockTyp.HEAD3: (nwStyles.H_SIZES.get(3, 1.0) * fPt) if hScale else fPt,
|
||
BlockTyp.HEAD4: (nwStyles.H_SIZES.get(4, 1.0) * fPt) if hScale else fPt,
|
||
}
|
||
|
||
self._mText = (fPx * self._marginText[0], fPx * self._marginText[1])
|
||
self._mMeta = (fPx * self._marginMeta[0], fPx * self._marginMeta[1])
|
||
self._mSep = (fPx * self._marginSep[0], fPx * self._marginSep[1])
|
||
|
||
self._mIndent = mPx * 2.0
|
||
self._tIndent = mPx * self._firstWidth
|
||
|
||
# Text Formats
|
||
# ============
|
||
|
||
self._blockFmt = QTextBlockFormat()
|
||
self._blockFmt.setTopMargin(self._mText[0])
|
||
self._blockFmt.setBottomMargin(self._mText[1])
|
||
self._blockFmt.setAlignment(QtAlignAbsolute)
|
||
self._blockFmt.setLineHeight(100.0*self._lineHeight, QtPropLineHeight)
|
||
|
||
self._charFmt = QTextCharFormat()
|
||
self._charFmt.setBackground(QtTransparent)
|
||
self._charFmt.setForeground(self._theme.text)
|
||
|
||
self._init = True
|
||
|
||
def doConvert(self) -> None:
|
||
"""Write text tokens into the document."""
|
||
if not self._init:
|
||
return
|
||
|
||
self._document.blockSignals(True)
|
||
cursor = QTextCursor(self._document)
|
||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||
|
||
for tType, tMeta, tText, tFormat, tStyle in self._blocks:
|
||
|
||
bFmt = QTextBlockFormat(self._blockFmt)
|
||
if tType in META_BLOCKS:
|
||
bFmt.setTopMargin(self._mMeta[0])
|
||
bFmt.setBottomMargin(self._mMeta[1])
|
||
elif tType == BlockTyp.SEP:
|
||
bFmt.setTopMargin(self._mSep[0])
|
||
bFmt.setBottomMargin(self._mSep[1])
|
||
|
||
if tStyle & BlockFmt.LEFT:
|
||
bFmt.setAlignment(QtAlignLeft)
|
||
elif tStyle & BlockFmt.RIGHT:
|
||
bFmt.setAlignment(QtAlignRight)
|
||
elif tStyle & BlockFmt.CENTRE:
|
||
bFmt.setAlignment(QtAlignCenter)
|
||
elif tStyle & BlockFmt.JUSTIFY:
|
||
bFmt.setAlignment(QtAlignJustify)
|
||
|
||
if tStyle & BlockFmt.PBB:
|
||
self._insertNewPageMarker(cursor)
|
||
bFmt.setPageBreakPolicy(QtPageBreakBefore)
|
||
if tStyle & BlockFmt.PBA:
|
||
bFmt.setPageBreakPolicy(QtPageBreakAfter)
|
||
|
||
if tStyle & BlockFmt.Z_BTM:
|
||
bFmt.setBottomMargin(0.0)
|
||
if tStyle & BlockFmt.Z_TOP:
|
||
bFmt.setTopMargin(0.0)
|
||
|
||
if tStyle & BlockFmt.IND_L:
|
||
bFmt.setLeftMargin(self._mIndent)
|
||
if tStyle & BlockFmt.IND_R:
|
||
bFmt.setRightMargin(self._mIndent)
|
||
if tStyle & BlockFmt.IND_T:
|
||
bFmt.setTextIndent(self._tIndent)
|
||
|
||
if tType == BlockTyp.TEXT:
|
||
newBlock(cursor, bFmt)
|
||
self._insertFragments(tText, tFormat, cursor, self._charFmt)
|
||
|
||
elif tType in HEADING_BLOCKS:
|
||
bFmt, cFmt = self._genHeadStyle(tType, tMeta, bFmt)
|
||
for tPart in tText.split("\n"):
|
||
newBlock(cursor, bFmt)
|
||
cursor.insertText(tPart, cFmt)
|
||
bFmt.setPageBreakPolicy(QtPageBreakAuto)
|
||
|
||
elif tType in META_BLOCKS:
|
||
newBlock(cursor, bFmt)
|
||
self._insertFragments(tText, tFormat, cursor, self._charFmt)
|
||
|
||
elif tType == BlockTyp.SEP:
|
||
newBlock(cursor, bFmt)
|
||
cursor.insertText(tText, self._charFmt)
|
||
|
||
elif tType == BlockTyp.SKIP:
|
||
newBlock(cursor, bFmt)
|
||
cursor.insertText(nwUnicode.U_NBSP, self._charFmt)
|
||
|
||
if tStyle & BlockFmt.PBA:
|
||
self._insertNewPageMarker(cursor)
|
||
|
||
self._document.blockSignals(False)
|
||
|
||
return
|
||
|
||
def saveDocument(self, path: Path) -> None:
|
||
"""Save the document as a PDF file."""
|
||
logger.info("Writing PDF at %d DPI", self._dpi)
|
||
|
||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
||
printer.setDocName(self._project.data.name)
|
||
printer.setCreator(f"novelWriter/{__version__}")
|
||
printer.setResolution(self._dpi)
|
||
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
||
printer.setPageSize(self._pageSize)
|
||
printer.setPageMargins(self._pageMargins, QPageLayout.Unit.Millimeter)
|
||
printer.setOutputFileName(str(path))
|
||
|
||
if layout := self._document.documentLayout():
|
||
layout.setPaintDevice(printer)
|
||
|
||
self._document.setPageSize(printer.pageRect(QPrinter.Unit.DevicePixel).size())
|
||
self._document.print(printer)
|
||
|
||
def closeDocument(self) -> None:
|
||
"""Run close document tasks."""
|
||
self._document.blockSignals(True)
|
||
|
||
# Replace fields if there are stats available
|
||
if self._usedFields and self._counts:
|
||
cursor = QTextCursor(self._document)
|
||
for pos, field in reversed(self._usedFields):
|
||
if (value := self._counts.get(field)) is not None:
|
||
cursor.setPosition(pos, QtMoveAnchor)
|
||
cursor.setPosition(pos + 1, QtKeepAnchor)
|
||
cursor.insertText(self._formatInt(value))
|
||
|
||
# Add footnotes
|
||
if self._usedNotes:
|
||
cursor = QTextCursor(self._document)
|
||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||
|
||
bFmt, cFmt = self._genHeadStyle(BlockTyp.HEAD4, "", self._blockFmt)
|
||
newBlock(cursor, bFmt)
|
||
cursor.insertText(self._localLookup("Footnotes"), cFmt)
|
||
|
||
for key, index in self._usedNotes.items():
|
||
if content := self._footnotes.get(key):
|
||
cFmt = QTextCharFormat(self._charFmt)
|
||
cFmt.setForeground(self._theme.code)
|
||
cFmt.setAnchor(True)
|
||
cFmt.setAnchorNames([f"footnote_{index}"])
|
||
newBlock(cursor, self._blockFmt)
|
||
cursor.insertText(f"{index}. ", cFmt)
|
||
self._insertFragments(*content, cursor, self._charFmt)
|
||
|
||
self._document.blockSignals(False)
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _insertFragments(
|
||
self, text: str, tFmt: T_Formats, cursor: QTextCursor, dFmt: QTextCharFormat
|
||
) -> None:
|
||
"""Apply formatting tags to text."""
|
||
cFmt = QTextCharFormat(dFmt)
|
||
temp = text.replace("\n", nwUnicode.U_LSEP)
|
||
start = 0
|
||
primary: QColor | None = None
|
||
for pos, fmt, data in tFmt:
|
||
|
||
# Insert buffer with previous format
|
||
cursor.insertText(stripEscape(temp[start:pos]), cFmt)
|
||
|
||
# Construct next format
|
||
if fmt == TextFmt.B_B:
|
||
cFmt.setFontWeight(QFont.Weight.Bold)
|
||
elif fmt == TextFmt.B_E:
|
||
cFmt.setFontWeight(self._dWeight)
|
||
elif fmt == TextFmt.I_B:
|
||
cFmt.setFontItalic(True)
|
||
elif fmt == TextFmt.I_E:
|
||
cFmt.setFontItalic(self._dItalic)
|
||
elif fmt == TextFmt.D_B:
|
||
cFmt.setFontStrikeOut(True)
|
||
elif fmt == TextFmt.D_E:
|
||
cFmt.setFontStrikeOut(self._dStrike)
|
||
elif fmt == TextFmt.U_B:
|
||
cFmt.setFontUnderline(True)
|
||
elif fmt == TextFmt.U_E:
|
||
cFmt.setFontUnderline(self._dUnderline)
|
||
elif fmt == TextFmt.M_B:
|
||
cFmt.setBackground(self._theme.highlight)
|
||
elif fmt == TextFmt.M_E:
|
||
cFmt.setBackground(QtTransparent)
|
||
elif fmt == TextFmt.SUP_B:
|
||
cFmt.setVerticalAlignment(QtVAlignSuper)
|
||
elif fmt == TextFmt.SUP_E:
|
||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||
elif fmt == TextFmt.SUB_B:
|
||
cFmt.setVerticalAlignment(QtVAlignSub)
|
||
elif fmt == TextFmt.SUB_E:
|
||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||
elif fmt == TextFmt.COL_B:
|
||
if color := self._classes.get(data):
|
||
cFmt.setForeground(color)
|
||
primary = color
|
||
elif fmt == TextFmt.COL_E:
|
||
cFmt.setForeground(self._theme.text)
|
||
primary = None
|
||
elif fmt == TextFmt.ANM_B:
|
||
if self._anchors:
|
||
cFmt.setAnchor(True)
|
||
cFmt.setAnchorNames([data])
|
||
elif fmt == TextFmt.ANM_E:
|
||
if self._anchors:
|
||
cFmt.setAnchor(False)
|
||
elif fmt == TextFmt.ARF_B:
|
||
if self._anchors:
|
||
cFmt.setFontUnderline(True)
|
||
cFmt.setAnchor(True)
|
||
cFmt.setAnchorHref(data)
|
||
elif fmt == TextFmt.ARF_E:
|
||
if self._anchors:
|
||
cFmt.setFontUnderline(False)
|
||
cFmt.setAnchor(False)
|
||
cFmt.setAnchorHref("")
|
||
elif fmt == TextFmt.HRF_B:
|
||
cFmt.setForeground(self._theme.link)
|
||
cFmt.setFontUnderline(True)
|
||
cFmt.setAnchor(True)
|
||
cFmt.setAnchorHref(data)
|
||
elif fmt == TextFmt.HRF_E:
|
||
cFmt.setForeground(primary or self._theme.text)
|
||
cFmt.setFontUnderline(self._dUnderline)
|
||
cFmt.setAnchor(False)
|
||
cFmt.setAnchorHref("")
|
||
elif fmt == TextFmt.FNOTE:
|
||
xFmt = QTextCharFormat(self._charFmt)
|
||
xFmt.setForeground(self._theme.code)
|
||
xFmt.setVerticalAlignment(QtVAlignSuper)
|
||
if data in self._footnotes:
|
||
index = len(self._usedNotes) + 1
|
||
self._usedNotes[data] = index
|
||
xFmt.setAnchor(True)
|
||
xFmt.setAnchorHref(f"#footnote_{index}")
|
||
xFmt.setFontUnderline(True)
|
||
cursor.insertText(f"[{index}]", xFmt)
|
||
else:
|
||
cursor.insertText("[ERR]", cFmt)
|
||
elif fmt == TextFmt.FIELD:
|
||
if field := data.partition(":")[2]:
|
||
self._usedFields.append((cursor.position(), field))
|
||
cursor.insertText("0", cFmt)
|
||
|
||
# Move pos for next pass
|
||
start = pos
|
||
|
||
# Insert whatever is left in the buffer
|
||
cursor.insertText(stripEscape(temp[start:]), cFmt)
|
||
|
||
def _insertNewPageMarker(self, cursor: QTextCursor) -> None:
|
||
"""Insert a new page marker."""
|
||
if self._newPage:
|
||
bgCol = QColor(self._theme.text)
|
||
bgCol.setAlphaF(0.1)
|
||
fgCol = QColor(self._theme.text)
|
||
fgCol.setAlphaF(0.8)
|
||
|
||
fFmt = QTextFrameFormat()
|
||
fFmt.setBorderStyle(QTextFrameFormat.BorderStyle.BorderStyle_None)
|
||
fFmt.setBackground(bgCol)
|
||
fFmt.setTopMargin(self._mSep[0])
|
||
fFmt.setBottomMargin(self._mSep[1])
|
||
|
||
bFmt = QTextBlockFormat(self._blockFmt)
|
||
bFmt.setAlignment(QtAlignCenter)
|
||
bFmt.setTopMargin(0.0)
|
||
bFmt.setBottomMargin(0.0)
|
||
bFmt.setLineHeight(100.0, QtPropLineHeight)
|
||
|
||
cFmt = QTextCharFormat(self._charFmt)
|
||
cFmt.setFontItalic(False)
|
||
cFmt.setFontUnderline(False)
|
||
cFmt.setFontStrikeOut(False)
|
||
cFmt.setFontWeight(QFont.Weight.Normal)
|
||
cFmt.setFontPointSize(0.75*self._textFont.pointSizeF())
|
||
cFmt.setForeground(fgCol)
|
||
|
||
cursor.insertFrame(fFmt)
|
||
cursor.setBlockFormat(bFmt)
|
||
cursor.insertText(self._project.localLookup("New Page"), cFmt)
|
||
if root := self._document.rootFrame():
|
||
cursor.swap(root.lastCursorPosition())
|
||
|
||
def _genHeadStyle(self, hType: BlockTyp, hKey: str, rFmt: QTextBlockFormat) -> T_TextStyle:
|
||
"""Generate a heading style set."""
|
||
mTop, mBottom = self._mHead.get(hType, (0.0, 0.0))
|
||
|
||
bFmt = QTextBlockFormat(rFmt)
|
||
bFmt.setTopMargin(mTop)
|
||
bFmt.setBottomMargin(mBottom)
|
||
|
||
hCol = self._colorHeads and hType != BlockTyp.TITLE
|
||
cFmt = QTextCharFormat(self._charFmt)
|
||
cFmt.setForeground(self._theme.head if hCol else self._theme.text)
|
||
cFmt.setFontWeight(self._hWeight)
|
||
cFmt.setFontPointSize(self._sHead.get(hType, 1.0))
|
||
if hKey and self._anchors:
|
||
cFmt.setAnchorNames([hKey])
|
||
cFmt.setAnchor(True)
|
||
|
||
return bFmt, cFmt
|