From b339c10ca091899ccadd373e5406239f4ee37568 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Tue, 17 Mar 2026 09:41:48 +0100 Subject: [PATCH] feat: use Worte/Weise labels and render metadata at page bottom (Closes #5) Add metadata_labels ("abbreviated"/"german") and metadata_position ("top"/"bottom") options to LayoutConfig. German labels use "Worte:" and "Weise:" instead of "T:" and "M:", with "Worte und Weise:" when lyricist and composer are the same person. Metadata at bottom position renders after notes with word-wrapping. MeasurementEngine accounts for two-line metadata in German label mode. Co-Authored-By: Claude Opus 4.6 --- .../songbook/layout/MeasurementEngine.kt | 11 ++- .../pfadfinder/songbook/model/BookConfig.kt | 4 +- .../songbook/parser/ConfigParserTest.kt | 25 ++++++ .../songbook/renderer/pdf/PdfBookRenderer.kt | 83 +++++++++++++++---- 4 files changed, 106 insertions(+), 17 deletions(-) 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 7e97502..740d2f0 100644 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt @@ -15,9 +15,16 @@ class MeasurementEngine( // Title height heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f - // Metadata line (composer/lyricist) + // Metadata lines (composer/lyricist) - may be 1 or 2 lines depending on label style if (song.composer != null || song.lyricist != null) { - heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f + val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f + val useGerman = config.layout.metadataLabels == "german" + if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) { + // Two separate lines: "Worte: ..." and "Weise: ..." + heightMm += metaLineHeight * 2 + } else { + heightMm += metaLineHeight + } } // Key/capo line diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt index a8ff335..5086a31 100644 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt @@ -51,7 +51,9 @@ data class LayoutConfig( val margins: Margins = Margins(), val chordLineSpacing: Float = 3f, // mm val verseSpacing: Float = 4f, // mm - val pageNumberPosition: String = "bottom-outer" + val pageNumberPosition: String = "bottom-outer", + val metadataLabels: String = "abbreviated", // "abbreviated" (M:/T:) or "german" (Worte:/Weise:) + val metadataPosition: String = "top" // "top" (after title) or "bottom" (bottom of last page) ) data class Margins( diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt index a92935a..cdbb844 100644 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt @@ -214,6 +214,31 @@ class ConfigParserTest { config.toc.highlightColumn.shouldBeNull() } + @Test + fun `parse config with german metadata labels`() { + val yaml = """ + book: + title: "Test" + layout: + metadata_labels: german + metadata_position: bottom + """.trimIndent() + val config = ConfigParser.parse(yaml) + config.layout.metadataLabels shouldBe "german" + config.layout.metadataPosition shouldBe "bottom" + } + + @Test + fun `parse config with default metadata settings`() { + val yaml = """ + book: + title: "Test" + """.trimIndent() + val config = ConfigParser.parse(yaml) + config.layout.metadataLabels shouldBe "abbreviated" + config.layout.metadataPosition shouldBe "top" + } + @Test fun `parse config ignores unknown properties`() { val yaml = """ 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 e8c5f38..77c9b14 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 @@ -104,6 +104,8 @@ class PdfBookRenderer : BookRenderer { ) { var y = contentTop + val renderMetaAtBottom = config.layout.metadataPosition == "bottom" + if (pageIndex == 0) { // Render title val titleFont = fontMetrics.getBaseFont(config.fonts.title) @@ -116,20 +118,22 @@ class PdfBookRenderer : BookRenderer { cb.endText() y -= titleSize * 1.5f - // Render metadata line (composer/lyricist) - val metaParts = mutableListOf() - song.composer?.let { metaParts.add("M: $it") } - song.lyricist?.let { metaParts.add("T: $it") } - if (metaParts.isNotEmpty()) { - val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) - val metaSize = config.fonts.metadata.size - cb.beginText() - cb.setFontAndSize(metaFont, metaSize) - cb.setColorFill(Color.GRAY) - cb.setTextMatrix(leftMargin, y - metaSize) - cb.showText(metaParts.joinToString(" / ")) - cb.endText() - y -= metaSize * 1.8f + // Render metadata line (composer/lyricist) - at top position only + if (!renderMetaAtBottom) { + val metaParts = buildMetadataLines(song, config) + if (metaParts.isNotEmpty()) { + val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) + val metaSize = config.fonts.metadata.size + for (metaLine in metaParts) { + cb.beginText() + cb.setFontAndSize(metaFont, metaSize) + cb.setColorFill(Color.GRAY) + cb.setTextMatrix(leftMargin, y - metaSize) + cb.showText(metaLine) + cb.endText() + y -= metaSize * 1.8f + } + } } // Render key and capo @@ -257,6 +261,28 @@ class PdfBookRenderer : BookRenderer { } } + // Render metadata at bottom of song page (if configured) + if (renderMetaAtBottom && pageIndex == 0) { + val metaParts = buildMetadataLines(song, config) + if (metaParts.isNotEmpty()) { + y -= 4f + val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) + val metaSize = config.fonts.metadata.size + for (metaLine in metaParts) { + val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth) + for (wrappedLine in wrappedLines) { + cb.beginText() + cb.setFontAndSize(metaFont, metaSize) + cb.setColorFill(Color.GRAY) + cb.setTextMatrix(leftMargin, y - metaSize) + cb.showText(wrappedLine) + cb.endText() + 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 @@ -270,6 +296,35 @@ class PdfBookRenderer : BookRenderer { } } + /** + * Build metadata lines based on configured label style. + * Returns a list of lines to render (may be empty). + */ + private fun buildMetadataLines(song: Song, config: BookConfig): List { + val useGerman = config.layout.metadataLabels == "german" + val lines = mutableListOf() + + if (useGerman) { + // German labels: "Worte und Weise:" when same person, otherwise separate + if (song.lyricist != null && song.composer != null && song.lyricist == song.composer) { + lines.add("Worte und Weise: ${song.lyricist}") + } else { + song.lyricist?.let { lines.add("Worte: $it") } + song.composer?.let { lines.add("Weise: $it") } + } + } else { + // Abbreviated labels on a single line + val parts = mutableListOf() + song.composer?.let { parts.add("M: $it") } + song.lyricist?.let { parts.add("T: $it") } + if (parts.isNotEmpty()) { + lines.add(parts.joinToString(" / ")) + } + } + + return lines + } + /** * Renders reference book abbreviations and page numbers as a footer row * at the bottom of the song page, above the page number. -- 2.49.1