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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user