feat: add support for foreword/preface pages (Closes #1)

Add ForewordConfig to BookConfig, Foreword model type, ForewordParser for
text files (quote/paragraphs/signatures), ForewordPage in PageContent,
pipeline integration to insert foreword after TOC, and PDF rendering with
styled quote, horizontal rule separator, word-wrapped paragraphs, and
right-aligned signatures.

Also adds Gradle wrapper and adjusts build toolchain for JDK 25 compat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #8.
This commit is contained in:
shahondin1624
2026-03-17 09:27:00 +01:00
parent e386501b57
commit 8e4728c55a
14 changed files with 792 additions and 7 deletions

View File

@@ -74,6 +74,14 @@ class PdfBookRenderer : BookRenderer {
cb.beginText()
cb.endText()
}
is PageContent.ForewordPage -> {
val leftMargin = if (isRightPage) marginInner else marginOuter
renderForewordPage(
cb, fontMetrics, config,
pageContent.foreword, pageContent.pageIndex,
contentTop, leftMargin, contentWidth
)
}
}
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
@@ -235,6 +243,119 @@ class PdfBookRenderer : BookRenderer {
}
}
private fun renderForewordPage(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
foreword: Foreword,
pageIndex: Int,
contentTop: Float,
leftMargin: Float,
contentWidth: Float
) {
var y = contentTop
val bodyFontSpec = config.fonts.lyrics
val bodyFont = fontMetrics.getBaseFont(bodyFontSpec)
val bodySize = bodyFontSpec.size
val lineHeight = bodySize * 1.5f
if (pageIndex == 0) {
// Page 1: Quote + separator + first paragraphs
// Render quote in italic (bold)
val quoteText = foreword.quote
if (quoteText != null) {
val quoteFont = fontMetrics.getBaseFontBold(bodyFontSpec)
val quoteSize = bodySize * 1.1f
// Word-wrap the quote text
val quoteLines = wrapText(quoteText, quoteFont, quoteSize, contentWidth)
for (quoteLine in quoteLines) {
cb.beginText()
cb.setFontAndSize(quoteFont, quoteSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - quoteSize)
cb.showText(quoteLine)
cb.endText()
y -= quoteSize * 1.5f
}
y -= 6f // gap before separator
// Horizontal rule
cb.setLineWidth(0.5f)
cb.setColorStroke(Color.GRAY)
cb.moveTo(leftMargin, y)
cb.lineTo(leftMargin + contentWidth, y)
cb.stroke()
y -= 10f // gap after separator
}
// Render body paragraphs
for (paragraph in foreword.paragraphs) {
val wrappedLines = wrapText(paragraph, bodyFont, bodySize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(bodyFont, bodySize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(leftMargin, y - bodySize)
cb.showText(wrappedLine)
cb.endText()
y -= lineHeight
}
y -= lineHeight * 0.5f // paragraph spacing
}
// Render signatures (right-aligned)
if (foreword.signatures.isNotEmpty()) {
y -= lineHeight // extra gap before signatures
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (signature in foreword.signatures) {
val sigWidth = metaFont.getWidthPoint(signature, metaSize)
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin + contentWidth - sigWidth, y - metaSize)
cb.showText(signature)
cb.endText()
y -= metaSize * 1.8f
}
}
} else {
// Page 2: blank (or overflow content if needed in the future)
cb.beginText()
cb.endText()
}
}
/**
* Simple word-wrap: splits text into lines that fit within maxWidth (in points).
*/
private fun wrapText(text: String, font: BaseFont, fontSize: Float, maxWidth: Float): List<String> {
val words = text.split(" ")
val lines = mutableListOf<String>()
var currentLine = StringBuilder()
for (word in words) {
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
val testWidth = font.getWidthPoint(testLine, fontSize)
if (testWidth <= maxWidth) {
currentLine = StringBuilder(testLine)
} else {
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
currentLine = StringBuilder(word)
}
}
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
return lines
}
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
try {
val img = Image.getInstance(imagePath)