feat: add intro page, rotated TOC headers, progress indicator, fix TOC padding (Closes #33)
- Add intro/title page before TOC (configurable via intro.enabled in songbook.yaml) - Rotate reference book column headers 90° in TOC to prevent text truncation - Add progress callback to SongbookPipeline.build() with CLI and GUI integration - Make GUI song loading async with spinner indicator - Fix TOC page padding: use actual rendered page count instead of blindly adding tocPages-1 blank pages - Pre-render TOC to measure actual pages needed (TocRenderer.measurePages) - Account for intro pages in pagination offset and page numbering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,11 +26,24 @@ class PdfBookRenderer : BookRenderer {
|
||||
val writer = PdfWriter.getInstance(document, output)
|
||||
document.open()
|
||||
|
||||
// Render TOC first
|
||||
// Render intro page (title page) if configured
|
||||
if (layout.introPages > 0) {
|
||||
val cb = writer.directContent
|
||||
renderIntroPage(cb, fontMetrics, config, pageSize)
|
||||
// Blank back of intro page (for double-sided printing)
|
||||
document.newPage()
|
||||
writer.directContent.let { c -> c.beginText(); c.endText() }
|
||||
document.newPage()
|
||||
}
|
||||
|
||||
// Render TOC
|
||||
if (layout.tocEntries.isNotEmpty()) {
|
||||
tocRenderer.render(document, writer, layout.tocEntries)
|
||||
// Add blank pages to fill TOC allocation
|
||||
repeat(layout.tocPages - 1) {
|
||||
// Pad with blank pages to fill the allocated TOC page count.
|
||||
// The table auto-paginates, so we only add the difference.
|
||||
val tocPagesUsed = writer.pageNumber - layout.introPages
|
||||
val paddingNeeded = maxOf(0, layout.tocPages - tocPagesUsed)
|
||||
repeat(paddingNeeded) {
|
||||
document.newPage()
|
||||
// Force new page even if empty
|
||||
writer.directContent.let { cb ->
|
||||
@@ -42,7 +55,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
}
|
||||
|
||||
// Render content pages
|
||||
var currentPageNum = layout.tocPages + 1
|
||||
var currentPageNum = layout.introPages + layout.tocPages + 1
|
||||
for (pageContent in layout.pages) {
|
||||
// Swap margins for left/right pages
|
||||
val isRightPage = currentPageNum % 2 == 1
|
||||
@@ -729,6 +742,64 @@ class PdfBookRenderer : BookRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderIntroPage(
|
||||
cb: PdfContentByte,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
pageSize: Rectangle
|
||||
) {
|
||||
val pageWidth = pageSize.width
|
||||
val pageHeight = pageSize.height
|
||||
|
||||
// Title centered on the page
|
||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||
val titleSize = config.fonts.title.size * 2.5f
|
||||
val title = config.book.title
|
||||
val titleWidth = titleFont.getWidthPoint(title, titleSize)
|
||||
val titleX = (pageWidth - titleWidth) / 2
|
||||
val titleY = pageHeight * 0.55f
|
||||
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(titleFont, titleSize)
|
||||
cb.setColorFill(Color.BLACK)
|
||||
cb.setTextMatrix(titleX, titleY)
|
||||
cb.showText(title)
|
||||
cb.endText()
|
||||
|
||||
// Subtitle below the title
|
||||
if (config.book.subtitle != null) {
|
||||
val subtitleSize = config.fonts.title.size * 1.2f
|
||||
val subtitle = config.book.subtitle!!
|
||||
val subtitleWidth = titleFont.getWidthPoint(subtitle, subtitleSize)
|
||||
val subtitleX = (pageWidth - subtitleWidth) / 2
|
||||
val subtitleY = titleY - titleSize * 1.8f
|
||||
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(titleFont, subtitleSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(subtitleX, subtitleY)
|
||||
cb.showText(subtitle)
|
||||
cb.endText()
|
||||
}
|
||||
|
||||
// Edition at the bottom of the page
|
||||
if (config.book.edition != null) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size * 1.2f
|
||||
val edition = config.book.edition!!
|
||||
val editionWidth = metaFont.getWidthPoint(edition, metaSize)
|
||||
val editionX = (pageWidth - editionWidth) / 2
|
||||
val editionY = pageHeight * 0.1f
|
||||
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(editionX, editionY)
|
||||
cb.showText(edition)
|
||||
cb.endText()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
||||
try {
|
||||
val img = Image.getInstance(imagePath)
|
||||
|
||||
@@ -12,6 +12,32 @@ class TocRenderer(
|
||||
// Light gray background for the highlighted column
|
||||
private val highlightColor = Color(220, 220, 220)
|
||||
|
||||
/**
|
||||
* Pre-renders the TOC to a temporary document and returns the number of pages needed,
|
||||
* rounded up to an even number for double-sided printing.
|
||||
*/
|
||||
fun measurePages(tocEntries: List<TocEntry>): Int {
|
||||
val pageSize = if (config.book.format == "A5") PageSize.A5 else PageSize.A4
|
||||
val marginInner = config.layout.margins.inner / 0.3528f
|
||||
val marginOuter = config.layout.margins.outer / 0.3528f
|
||||
val marginTop = config.layout.margins.top / 0.3528f
|
||||
val marginBottom = config.layout.margins.bottom / 0.3528f
|
||||
|
||||
val baos = java.io.ByteArrayOutputStream()
|
||||
val doc = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
|
||||
val writer = PdfWriter.getInstance(doc, baos)
|
||||
doc.open()
|
||||
render(doc, writer, tocEntries)
|
||||
doc.close()
|
||||
|
||||
val reader = PdfReader(baos.toByteArray())
|
||||
val pageCount = reader.numberOfPages
|
||||
reader.close()
|
||||
|
||||
// Round to even for double-sided printing
|
||||
return if (pageCount % 2 == 0) pageCount else pageCount + 1
|
||||
}
|
||||
|
||||
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
||||
@@ -30,19 +56,18 @@ class TocRenderer(
|
||||
val table = PdfPTable(numCols)
|
||||
table.widthPercentage = 100f
|
||||
|
||||
// Set column widths: title takes most space
|
||||
// Set column widths: title takes most space, ref columns need room for 3-digit numbers
|
||||
val widths = FloatArray(numCols)
|
||||
widths[0] = 10f // title
|
||||
widths[1] = 1.5f // page
|
||||
widths[0] = 12f // title
|
||||
widths[1] = 1.5f // page
|
||||
for (i in refBooks.indices) {
|
||||
widths[2 + i] = 1.5f
|
||||
widths[2 + i] = 1.5f // enough for 3-digit page numbers; headers are rotated 90°
|
||||
}
|
||||
table.setWidths(widths)
|
||||
|
||||
// Determine which column index should be highlighted
|
||||
val highlightAbbrev = config.toc.highlightColumn
|
||||
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
|
||||
// Check "Seite" (page) column first - the current book's page number column
|
||||
if (highlightAbbrev == "Seite") {
|
||||
1
|
||||
} else {
|
||||
@@ -51,13 +76,13 @@ class TocRenderer(
|
||||
}
|
||||
} else null
|
||||
|
||||
// Header row
|
||||
// Header row — reference book columns are rotated 90°
|
||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
||||
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
||||
for ((i, book) in refBooks.withIndex()) {
|
||||
val isHighlighted = highlightColumnIndex == 2 + i
|
||||
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
|
||||
table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted))
|
||||
}
|
||||
table.headerRows = 1
|
||||
|
||||
@@ -71,7 +96,7 @@ class TocRenderer(
|
||||
for ((i, book) in refBooks.withIndex()) {
|
||||
val ref = entry.references[book.abbreviation]
|
||||
val isHighlighted = highlightColumnIndex == 2 + i
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_CENTER, isHighlighted = isHighlighted))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +108,27 @@ class TocRenderer(
|
||||
cell.borderWidth = 0f
|
||||
cell.borderWidthBottom = 0.5f
|
||||
cell.paddingBottom = 4f
|
||||
cell.verticalAlignment = Element.ALIGN_BOTTOM
|
||||
if (isHighlighted) {
|
||||
cell.backgroundColor = highlightColor
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a header cell with text rotated 90° counterclockwise.
|
||||
* Used for reference book column headers to save horizontal space.
|
||||
*/
|
||||
private fun rotatedHeaderCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.borderWidthBottom = 0.5f
|
||||
cell.rotation = 90
|
||||
cell.horizontalAlignment = Element.ALIGN_CENTER
|
||||
cell.verticalAlignment = Element.ALIGN_MIDDLE
|
||||
// Ensure cell is tall enough for the rotated text
|
||||
val textWidth = font.baseFont.getWidthPoint(text, font.size)
|
||||
cell.minimumHeight = textWidth + 8f
|
||||
if (isHighlighted) {
|
||||
cell.backgroundColor = highlightColor
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user