Compare commits

2 Commits

Author SHA1 Message Date
shahondin1624
0139327034 feat: highlight the current book's column in the TOC (Closes #6)
Add TocConfig with highlightColumn field to BookConfig. TocRenderer now
applies a light gray background shading to the designated column header
and data cells, making it easy to visually distinguish the current book's
page numbers from reference book columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:35:47 +01:00
shahondin1624
ba035159f7 feat: display reference book page numbers in song page footer (Closes #3)
Render a two-row footer at the bottom of each song page showing reference
book abbreviations as column headers with corresponding page numbers below.
A thin separator line is drawn above the footer. MeasurementEngine now
reserves vertical space for the reference footer when reference books are
configured and the song has references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:32:52 +01:00
6 changed files with 221 additions and 11 deletions

View File

@@ -66,6 +66,13 @@ class MeasurementEngine(
} }
} }
// Reference book footer: reserve space for abbreviation row + page number row + separator line
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size)
heightMm += metaLineHeight * 1.4f * 2 // two rows (headers + numbers)
heightMm += metaLineHeight * 0.5f // separator line gap
}
val pageCount = if (heightMm <= contentHeightMm) 1 else 2 val pageCount = if (heightMm <= contentHeightMm) 1 else 2
return MeasuredSong(song, heightMm, pageCount) return MeasuredSong(song, heightMm, pageCount)
} }

View File

@@ -258,4 +258,70 @@ class MeasurementEngineTest {
labeledHeight shouldBeGreaterThan unlabeledHeight labeledHeight shouldBeGreaterThan unlabeledHeight
} }
@Test
fun `references add footer height when reference books configured`() {
val configWithRefs = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
)
)
val engineWithRefs = MeasurementEngine(fontMetrics, configWithRefs)
val songWithRefs = Song(
title = "With Refs",
references = mapOf("mo" to 42, "pl" to 17),
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutRefs = Song(
title = "No Refs",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val heightWith = engineWithRefs.measure(songWithRefs).totalHeightMm
val heightWithout = engineWithRefs.measure(songWithoutRefs).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `references do not add height when no reference books configured`() {
val songWithRefs = Song(
title = "With Refs",
references = mapOf("mo" to 42),
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutRefs = Song(
title = "No Refs",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
// Default config has no reference books
val heightWith = engine.measure(songWithRefs).totalHeightMm
val heightWithout = engine.measure(songWithoutRefs).totalHeightMm
// Should be the same since no reference books are configured
heightWith shouldBe heightWithout
}
} }

View File

@@ -8,7 +8,12 @@ data class BookConfig(
val images: ImagesConfig = ImagesConfig(), val images: ImagesConfig = ImagesConfig(),
val referenceBooks: List<ReferenceBook> = emptyList(), val referenceBooks: List<ReferenceBook> = emptyList(),
val output: OutputConfig = OutputConfig(), val output: OutputConfig = OutputConfig(),
val foreword: ForewordConfig? = null val foreword: ForewordConfig? = null,
val toc: TocConfig = TocConfig()
)
data class TocConfig(
val highlightColumn: String? = null // abbreviation of the column to highlight (e.g. "CL")
) )
data class ForewordConfig( data class ForewordConfig(

View File

@@ -192,6 +192,28 @@ class ConfigParserTest {
config.foreword.shouldBeNull() config.foreword.shouldBeNull()
} }
@Test
fun `parse config with toc highlight column`() {
val yaml = """
book:
title: "Test"
toc:
highlight_column: "CL"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.toc.highlightColumn shouldBe "CL"
}
@Test
fun `parse config without toc section uses defaults`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.toc.highlightColumn.shouldBeNull()
}
@Test @Test
fun `parse config ignores unknown properties`() { fun `parse config ignores unknown properties`() {
val yaml = """ val yaml = """

View File

@@ -241,6 +241,87 @@ class PdfBookRenderer : BookRenderer {
y -= metaSize * 1.5f y -= metaSize * 1.5f
} }
} }
// Render reference book footer on the last page of the song
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
val isLastPage = (pageIndex == 0) // For now, all content renders on page 0
if (isLastPage) {
renderReferenceFooter(
cb, fontMetrics, config, song,
leftMargin, contentWidth,
config.layout.margins.bottom / 0.3528f
)
}
}
}
/**
* Renders reference book abbreviations and page numbers as a footer row
* at the bottom of the song page, above the page number.
*/
private fun renderReferenceFooter(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
song: Song,
leftMargin: Float,
contentWidth: Float,
bottomMargin: Float
) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
val lineHeight = metaSize * 1.4f
// Position: just above the page number area
val footerY = bottomMargin + lineHeight * 0.5f
// Map book IDs to abbreviations
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
val books = config.referenceBooks
// Calculate column widths: evenly distribute across content width
val colWidth = contentWidth / books.size
// Row 1: Abbreviation headers
for ((i, book) in books.withIndex()) {
val x = leftMargin + i * colWidth
val abbr = book.abbreviation
val textWidth = metaFont.getWidthPoint(abbr, metaSize)
// Center text in column
val textX = x + (colWidth - textWidth) / 2
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(textX, footerY + lineHeight)
cb.showText(abbr)
cb.endText()
}
// Row 2: Page numbers
for ((i, book) in books.withIndex()) {
val x = leftMargin + i * colWidth
val pageNum = song.references[book.id]
if (pageNum != null) {
val pageText = pageNum.toString()
val textWidth = metaFont.getWidthPoint(pageText, metaSize)
val textX = x + (colWidth - textWidth) / 2
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(textX, footerY)
cb.showText(pageText)
cb.endText()
}
}
// Draw a thin line above the footer
cb.setLineWidth(0.3f)
cb.setColorStroke(Color.LIGHT_GRAY)
cb.moveTo(leftMargin, footerY + lineHeight * 1.5f)
cb.lineTo(leftMargin + contentWidth, footerY + lineHeight * 1.5f)
cb.stroke()
} }
private fun renderForewordPage( private fun renderForewordPage(

View File

@@ -3,11 +3,15 @@ package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.* import com.lowagie.text.*
import com.lowagie.text.pdf.* import com.lowagie.text.pdf.*
import de.pfadfinder.songbook.model.* import de.pfadfinder.songbook.model.*
import java.awt.Color
class TocRenderer( class TocRenderer(
private val fontMetrics: PdfFontMetrics, private val fontMetrics: PdfFontMetrics,
private val config: BookConfig private val config: BookConfig
) { ) {
// Light gray background for the highlighted column
private val highlightColor = Color(220, 220, 220)
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) { fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
val tocFont = fontMetrics.getBaseFont(config.fonts.toc) val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc) val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
@@ -35,12 +39,25 @@ class TocRenderer(
} }
table.setWidths(widths) 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 {
val refIndex = refBooks.indexOfFirst { it.abbreviation == highlightAbbrev }
if (refIndex >= 0) 2 + refIndex else null
}
} else null
// Header row // Header row
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD) val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
table.addCell(headerCell("Titel", headerFont)) table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
table.addCell(headerCell("Seite", headerFont)) table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
for (book in refBooks) { for ((i, book) in refBooks.withIndex()) {
table.addCell(headerCell(book.abbreviation, headerFont)) val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
} }
table.headerRows = 1 table.headerRows = 1
@@ -49,31 +66,43 @@ class TocRenderer(
val aliasFont = Font(tocFont, fontSize, Font.ITALIC) val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
for (entry in tocEntries.sortedBy { it.title.lowercase() }) { for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
val font = if (entry.isAlias) aliasFont else entryFont val font = if (entry.isAlias) aliasFont else entryFont
table.addCell(entryCell(entry.title, font)) table.addCell(entryCell(entry.title, font, isHighlighted = false))
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT)) table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1))
for (book in refBooks) { for ((i, book) in refBooks.withIndex()) {
val ref = entry.references[book.abbreviation] val ref = entry.references[book.abbreviation]
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT)) val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
} }
} }
document.add(table) document.add(table)
} }
private fun headerCell(text: String, font: Font): PdfPCell { private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
val cell = PdfPCell(Phrase(text, font)) val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f cell.borderWidth = 0f
cell.borderWidthBottom = 0.5f cell.borderWidthBottom = 0.5f
cell.paddingBottom = 4f cell.paddingBottom = 4f
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell return cell
} }
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell { private fun entryCell(
text: String,
font: Font,
alignment: Int = Element.ALIGN_LEFT,
isHighlighted: Boolean = false
): PdfPCell {
val cell = PdfPCell(Phrase(text, font)) val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f cell.borderWidth = 0f
cell.horizontalAlignment = alignment cell.horizontalAlignment = alignment
cell.paddingTop = 1f cell.paddingTop = 1f
cell.paddingBottom = 1f cell.paddingBottom = 1f
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell return cell
} }
} }