Fix page breaks in PDFs (#2416)

This commit is contained in:
Veronica Berglyd Olsen
2025-06-18 20:21:09 +02:00
committed by GitHub
9 changed files with 104 additions and 78 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ from novelwriter.common import (
NWConfigParser, checkInt, checkPath, describeFont, fontMatcher,
formatTimeStamp, processDialogSymbols, simplified
)
from novelwriter.constants import nwFiles, nwHtmlUnicode, nwQuotes, nwUnicode
from novelwriter.constants import nwFiles, nwQuotes, nwUnicode
from novelwriter.error import formatException, logException
if TYPE_CHECKING:
@@ -892,7 +892,7 @@ class Config:
"""
self.splashMessage(f"Initialising {kind} font: {font.family()}")
metrics = QFontMetrics(font)
for char in nwHtmlUnicode.U_TO_H.keys():
for char in nwUnicode.UI_SYMBOLS:
if not metrics.inFont(char): # type: ignore
logger.warning("No glyph U+%04x in font", ord(char)) # pragma: no cover
return
+12 -19
View File
@@ -586,21 +586,14 @@ class nwUnicode:
U_TIMES = "\u00d7" # Multiplication sign
U_DIVIDE = "\u00f7" # Division sign
# Arrows
U_UTRI = "\u25b2" # Up-pointing triangle
U_UTRIS = "\u25b4" # Up-pointing triangle, small
U_RTRI = "\u25b6" # Right-pointing triangle
U_RTRIS = "\u25b8" # Right-pointing triangle, small
U_DTRI = "\u25bc" # Down-pointing triangle
U_DTRIS = "\u25be" # Down-pointing triangle, small
U_LTRI = "\u25c0" # Left-pointing triangle
U_LTRIS = "\u25c2" # Left-pointing triangle, small
# Special
U_UNKN = "\ufffd" # Unknown character
U_NAC1 = "\ufffe" # Not a character
U_NAC2 = "\uffff" # Not a character
# Placeholders
U_LBREAK = "\u21b2" # Downwards Arrow With Tip Leftwards
# HTML Equivalents
# ================
@@ -655,15 +648,15 @@ class nwUnicode:
H_TIMES = "×"
H_DIVIDE = "÷"
# Arrows
H_UTRI = "▲"
H_UTRIS = "▴"
H_RTRI = "▶"
H_RTRIS = "▸"
H_DTRI = "▼"
H_DTRIS = "▾"
H_LTRI = "◀"
H_LTRIS = "◂"
# Unicode symbols expected to be used on the UI
UI_SYMBOLS: Final[list[str]] = [
U_QUOT, U_APOS, U_LAQUO, U_RAQUO, U_LSQUO, U_RSQUO, U_SBQUO, U_SUQUO,
U_LDQUO, U_RDQUO, U_BDQUO, U_UDQUO, U_LSAQUO, U_RSAQUO, U_BDRQUO,
U_LCQUO, U_RCQUO, U_LWCQUO, U_RWCQUO, U_FGDASH, U_ENDASH, U_EMDASH,
U_HBAR, U_HELLIP, U_MAPOS, U_PRIME, U_DPRIME, U_NBSP, U_THSP, U_THNBSP,
U_ENSP, U_EMSP, U_MMSP, U_CHECK, U_CROSS, U_BULL, U_TRBULL, U_HYBULL,
U_FLOWER, U_PERMIL, U_DEGREE, U_MINUS, U_TIMES, U_DIVIDE, U_LBREAK,
]
class nwHtmlUnicode:
+23 -17
View File
@@ -1168,12 +1168,18 @@ class Tokenizer(ABC):
class HeadingFormatter:
def __init__(self, project: NWProject) -> None:
def __init__(
self,
project: NWProject,
chapter: int = 0,
scene: int = 0,
absolute: int = 0,
) -> None:
self._project = project
self._handle = None
self._chCount = 0
self._scChCount = 0
self._scAbsCount = 0
self._chapter = chapter
self._scene = scene
self._absolute = absolute
return
def setHandle(self, tHandle: str | None) -> None:
@@ -1183,42 +1189,42 @@ class HeadingFormatter:
def incChapter(self) -> None:
"""Increment the chapter counter."""
self._chCount += 1
self._chapter += 1
return
def incScene(self) -> None:
"""Increment the scene counters."""
self._scChCount += 1
self._scAbsCount += 1
self._scene += 1
self._absolute += 1
return
def resetAll(self) -> None:
"""Reset all counters."""
self._chCount = 0
self._scChCount = 0
self._scAbsCount = 0
self._chapter = 0
self._scene = 0
self._absolute = 0
return
def resetScene(self) -> None:
"""Reset the chapter scene counter."""
self._scChCount = 0
self._scene = 0
return
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._chCount))
hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scChCount))
hFormat = hFormat.replace(nwHeadFmt.SC_ABS, str(self._scAbsCount))
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._chCount)
chWord = self._project.localLookup(self._chapter)
hFormat = hFormat.replace(nwHeadFmt.CH_WORD, chWord)
if nwHeadFmt.CH_ROML in hFormat:
chRom = numberToRoman(self._chCount, toLower=True)
chRom = numberToRoman(self._chapter, toLower=True)
hFormat = hFormat.replace(nwHeadFmt.CH_ROML, chRom)
if nwHeadFmt.CH_ROMU in hFormat:
chRom = numberToRoman(self._chCount, toLower=False)
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:
+6 -3
View File
@@ -40,8 +40,9 @@ from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, s
from novelwriter.formats.tokenizer import HEADINGS, Tokenizer
from novelwriter.types import (
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
QtKeepAnchor, QtMoveAnchor, QtPageBreakAfter, QtPageBreakBefore,
QtPropLineHeight, QtTransparent, QtVAlignNormal, QtVAlignSub, QtVAlignSuper
QtKeepAnchor, QtMoveAnchor, QtPageBreakAfter, QtPageBreakAuto,
QtPageBreakBefore, QtPropLineHeight, QtTransparent, QtVAlignNormal,
QtVAlignSub, QtVAlignSuper
)
if TYPE_CHECKING:
@@ -253,8 +254,10 @@ class ToQTextDocument(Tokenizer):
elif tType in HEADINGS:
bFmt, cFmt = self._genHeadStyle(tType, tMeta, bFmt)
for tPart in tText.split("\n"):
newBlock(cursor, bFmt)
cursor.insertText(tText, cFmt)
cursor.insertText(tPart, cFmt)
bFmt.setPageBreakPolicy(QtPageBreakAuto)
elif tType == BlockTyp.SEP:
newBlock(cursor, bFmt)
+4 -7
View File
@@ -43,7 +43,7 @@ from PyQt6.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import fuzzyTime, qtLambda
from novelwriter.constants import nwLabels, nwStats, trStats
from novelwriter.constants import nwHeadFmt, nwLabels, nwStats, nwUnicode, trStats
from novelwriter.core.buildsettings import BuildCollection, BuildSettings
from novelwriter.core.docbuild import NWBuildDocument
from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton, NToolDialog
@@ -595,11 +595,7 @@ class _DetailsWidget(QWidget):
item.addChild(sub)
# Headings
hFmt = HeadingFormatter(SHARED.project)
hFmt.incChapter()
hFmt.incScene()
hFmt.resetScene()
hFmt.incScene()
hFmt = HeadingFormatter(SHARED.project, 7, 5, 23)
title = self.tr("Title")
hidden = self.tr("Hidden")
@@ -620,7 +616,8 @@ class _DetailsWidget(QWidget):
if build.getBool(hHide):
sub.setText(1, f"[{hidden}]")
else:
sub.setText(1, hFmt.apply(build.getStr(hFormat), title, 0))
preview = build.getStr(hFormat).replace(nwHeadFmt.BR, nwUnicode.U_LBREAK)
sub.setText(1, hFmt.apply(preview, title, 0))
item.addChild(sub)
# Text Content
+19 -15
View File
@@ -38,7 +38,7 @@ from PyQt6.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import describeFont, fontMatcher, qtAddAction, qtLambda
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, trConst
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwUnicode, trConst
from novelwriter.core.buildsettings import BuildSettings, FilterMode
from novelwriter.extensions.configlayout import (
NColorLabel, NFixedPage, NScrollableForm, NScrollablePage
@@ -795,12 +795,15 @@ class _HeadingsTab(NScrollablePage):
def loadContent(self) -> None:
"""Populate the widgets."""
self.fmtPart.setText(self._build.getStr("headings.fmtPart"))
self.fmtChapter.setText(self._build.getStr("headings.fmtChapter"))
self.fmtUnnumbered.setText(self._build.getStr("headings.fmtUnnumbered"))
self.fmtScene.setText(self._build.getStr("headings.fmtScene"))
self.fmtAScene.setText(self._build.getStr("headings.fmtAltScene"))
self.fmtSection.setText(self._build.getStr("headings.fmtSection"))
def fmtBreak(text: str) -> str:
return text.replace(nwHeadFmt.BR, nwUnicode.U_LBREAK)
self.fmtPart.setText(fmtBreak(self._build.getStr("headings.fmtPart")))
self.fmtChapter.setText(fmtBreak(self._build.getStr("headings.fmtChapter")))
self.fmtUnnumbered.setText(fmtBreak(self._build.getStr("headings.fmtUnnumbered")))
self.fmtScene.setText(fmtBreak(self._build.getStr("headings.fmtScene")))
self.fmtAScene.setText(fmtBreak(self._build.getStr("headings.fmtAltScene")))
self.fmtSection.setText(fmtBreak(self._build.getStr("headings.fmtSection")))
self.swtPart.setChecked(self._build.getBool("headings.hidePart"))
self.swtChapter.setChecked(self._build.getBool("headings.hideChapter"))
@@ -880,7 +883,7 @@ class _HeadingsTab(NScrollablePage):
text = ""
label = self.tr("None")
self.editTextBox.setPlainText(text.replace(nwHeadFmt.BR, "\n"))
self.editTextBox.setPlainText(text.replace(nwUnicode.U_LBREAK, "\n"))
self.lblEditForm.setText(self.tr("Editing: {0}").format(label))
return
@@ -893,25 +896,26 @@ class _HeadingsTab(NScrollablePage):
def _saveFormat(self) -> None:
"""Save the format from the edit text box."""
heading = self._editing
text = self.editTextBox.toPlainText().strip().replace("\n", nwHeadFmt.BR)
text = self.editTextBox.toPlainText().strip().replace("\n", nwUnicode.U_LBREAK)
value = text.replace(nwUnicode.U_LBREAK, nwHeadFmt.BR)
if heading == self.EDIT_TITLE:
self.fmtPart.setText(text)
self._build.setValue("headings.fmtPart", text)
self._build.setValue("headings.fmtPart", value)
elif heading == self.EDIT_CHAPTER:
self.fmtChapter.setText(text)
self._build.setValue("headings.fmtChapter", text)
self._build.setValue("headings.fmtChapter", value)
elif heading == self.EDIT_UNNUM:
self.fmtUnnumbered.setText(text)
self._build.setValue("headings.fmtUnnumbered", text)
self._build.setValue("headings.fmtUnnumbered", value)
elif heading == self.EDIT_SCENE:
self.fmtScene.setText(text)
self._build.setValue("headings.fmtScene", text)
self._build.setValue("headings.fmtScene", value)
elif heading == self.EDIT_HSCENE:
self.fmtAScene.setText(text)
self._build.setValue("headings.fmtAltScene", text)
self._build.setValue("headings.fmtAltScene", value)
elif heading == self.EDIT_SECTION:
self.fmtSection.setText(text)
self._build.setValue("headings.fmtSection", text)
self._build.setValue("headings.fmtSection", value)
else:
return
+1
View File
@@ -52,6 +52,7 @@ QtVAlignSuper = QTextCharFormat.VerticalAlignment.AlignSuperScript
QtPageBreakBefore = QTextFormat.PageBreakFlag.PageBreak_AlwaysBefore
QtPageBreakAfter = QTextFormat.PageBreakFlag.PageBreak_AlwaysAfter
QtPageBreakAuto = QTextFormat.PageBreakFlag.PageBreak_Auto
QtPropLineHeight = 1 # QTextBlockFormat.LineHeightTypes.ProportionalHeight
+8 -8
View File
@@ -1654,7 +1654,7 @@ def testFmtToken_ProcessHeaders(mockGUI):
# H2: Chapter Word Number
tokens._text = "## Chapter\n"
tokens.setChapterFormat(f"Chapter {nwHeadFmt.CH_WORD}")
tokens._hFormatter._chCount = 0
tokens._hFormatter._chapter = 0
tokens.tokenizeText()
assert tokens._blocks == [
(BlockTyp.HEAD1, TM1, "Chapter One", [], BlockFmt.PBB),
@@ -1728,8 +1728,8 @@ def testFmtToken_ProcessHeaders(mockGUI):
# H3: Scene w/Absolute Number
tokens._text = "### A Scene\n"
tokens.setSceneFormat(f"Scene {nwHeadFmt.SC_ABS}", False)
tokens._hFormatter._scAbsCount = 0
tokens._hFormatter._scChCount = 0
tokens._hFormatter._scene = 0
tokens._hFormatter._absolute = 0
tokens.tokenizeText()
assert tokens._blocks == [
(BlockTyp.HEAD2, TM1, "Scene 1", [], BlockFmt.NONE),
@@ -1738,8 +1738,8 @@ def testFmtToken_ProcessHeaders(mockGUI):
# H3: Scene w/Chapter Number
tokens._text = "### A Scene\n"
tokens.setSceneFormat(f"Scene {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM}", False)
tokens._hFormatter._scAbsCount = 0
tokens._hFormatter._scChCount = 1
tokens._hFormatter._scene = 1
tokens._hFormatter._absolute = 0
tokens.tokenizeText()
assert tokens._blocks == [
(BlockTyp.HEAD2, TM1, "Scene 3.2", [], BlockFmt.NONE),
@@ -2544,9 +2544,9 @@ def testFmtToken_HeadingFormatter(fncPath, mockGUI, mockRnd):
# Special Formats
# ===============
formatter._chCount = 2
formatter._scChCount = 3
formatter._scAbsCount = 8
formatter._chapter = 2
formatter._scene = 3
formatter._absolute = 8
# Chapter Number Word
cFormat = f"Chapter {nwHeadFmt.CH_WORD}, Scene {nwHeadFmt.SC_NUM} - {nwHeadFmt.TITLE}"
+28 -6
View File
@@ -25,14 +25,15 @@ import pytest
from PyQt6.QtGui import QFont, QTextBlock, QTextCharFormat, QTextCursor
from novelwriter import CONFIG
from novelwriter.constants import nwUnicode
from novelwriter.constants import nwHeadFmt, nwUnicode
from novelwriter.core.project import NWProject
from novelwriter.enum import nwComment
from novelwriter.formats.shared import BlockFmt, BlockTyp, TextDocumentTheme
from novelwriter.formats.toqdoc import ToQTextDocument
from novelwriter.types import (
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
QtPageBreakAfter, QtTransparent, QtVAlignNormal, QtVAlignSub, QtVAlignSuper
QtPageBreakAfter, QtPageBreakAuto, QtPageBreakBefore, QtTransparent,
QtVAlignNormal, QtVAlignSub, QtVAlignSuper
)
THEME = TextDocumentTheme()
@@ -54,6 +55,11 @@ def testFmtToQTextDocument_ConvertHeaders(mockGUI):
doc._isNovel = True
doc._isFirst = True
# Add a line break in chapter header format, see #2415
doc.setChapterFormat(f"{nwHeadFmt.CH_NUM}{nwHeadFmt.BR}{nwHeadFmt.TITLE}")
# Populate
doc._text = (
"#! Title\n"
"# Partition\n"
@@ -63,12 +69,13 @@ def testFmtToQTextDocument_ConvertHeaders(mockGUI):
)
doc.tokenizeText()
doc.doConvert()
assert doc.document.blockCount() == 5
assert doc.document.blockCount() == 6
# Title
block = doc.document.findBlockByNumber(0)
assert block.text() == "Title"
bFmt = block.blockFormat()
assert bFmt.pageBreakPolicy() == QtPageBreakAuto # Expected on first title
assert bFmt.topMargin() == doc._mHead[BlockTyp.TITLE][0]
assert bFmt.bottomMargin() == doc._mHead[BlockTyp.TITLE][1]
cFmt = charFmtInBlock(block, 1)
@@ -87,10 +94,23 @@ def testFmtToQTextDocument_ConvertHeaders(mockGUI):
assert cFmt.fontPointSize() == doc._sHead[BlockTyp.PART]
assert cFmt.foreground().color() == THEME.head
# Chapter
# Chapter, Line 1
block = doc.document.findBlockByNumber(2)
assert block.text() == "1"
bFmt = block.blockFormat()
assert bFmt.pageBreakPolicy() == QtPageBreakBefore
assert bFmt.topMargin() == doc._mHead[BlockTyp.HEAD1][0]
assert bFmt.bottomMargin() == doc._mHead[BlockTyp.HEAD1][1]
cFmt = charFmtInBlock(block, 1)
assert cFmt.fontWeight() == QFont.Weight.Bold
assert cFmt.fontPointSize() == doc._sHead[BlockTyp.HEAD1]
assert cFmt.foreground().color() == THEME.head
# Chapter, Line 2
block = doc.document.findBlockByNumber(3)
assert block.text() == "Chapter"
bFmt = block.blockFormat()
assert bFmt.pageBreakPolicy() == QtPageBreakAuto # Important! See #2415
assert bFmt.topMargin() == doc._mHead[BlockTyp.HEAD1][0]
assert bFmt.bottomMargin() == doc._mHead[BlockTyp.HEAD1][1]
cFmt = charFmtInBlock(block, 1)
@@ -99,9 +119,10 @@ def testFmtToQTextDocument_ConvertHeaders(mockGUI):
assert cFmt.foreground().color() == THEME.head
# Scene
block = doc.document.findBlockByNumber(3)
block = doc.document.findBlockByNumber(4)
assert block.text() == "Scene"
bFmt = block.blockFormat()
assert bFmt.pageBreakPolicy() == QtPageBreakAuto
assert bFmt.topMargin() == doc._mHead[BlockTyp.HEAD2][0]
assert bFmt.bottomMargin() == doc._mHead[BlockTyp.HEAD2][1]
cFmt = charFmtInBlock(block, 1)
@@ -110,9 +131,10 @@ def testFmtToQTextDocument_ConvertHeaders(mockGUI):
assert cFmt.foreground().color() == THEME.head
# Section
block = doc.document.findBlockByNumber(4)
block = doc.document.findBlockByNumber(5)
assert block.text() == "Section"
bFmt = block.blockFormat()
assert bFmt.pageBreakPolicy() == QtPageBreakAuto
assert bFmt.topMargin() == doc._mHead[BlockTyp.HEAD3][0]
assert bFmt.bottomMargin() == doc._mHead[BlockTyp.HEAD3][1]
cFmt = charFmtInBlock(block, 1)