From ba035159f73911cf203de16857722b46f2a035a9 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Tue, 17 Mar 2026 09:32:09 +0100 Subject: [PATCH] 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 --- .../songbook/layout/MeasurementEngine.kt | 7 ++ .../songbook/layout/MeasurementEngineTest.kt | 66 +++++++++++++++ .../songbook/renderer/pdf/PdfBookRenderer.kt | 81 +++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt index 0c615d7..0bbad8b 100644 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt @@ -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) } diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt index 7d40215..3393d2e 100644 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt @@ -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 + } } diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt index 1332233..fd12761 100644 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt @@ -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( -- 2.49.1