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>
This commit was merged in pull request #9.
This commit is contained in:
shahondin1624
2026-03-17 09:32:09 +01:00
committed by shahondin1624
parent 8e4728c55a
commit ba035159f7
3 changed files with 154 additions and 0 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
return MeasuredSong(song, heightMm, pageCount)
}

View File

@@ -258,4 +258,70 @@ class MeasurementEngineTest {
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

@@ -241,6 +241,87 @@ class PdfBookRenderer : BookRenderer {
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(