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 <noreply@anthropic.com>
This commit was merged in pull request #12.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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<String>()
|
||||
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<String> {
|
||||
val useGerman = config.layout.metadataLabels == "german"
|
||||
val lines = mutableListOf<String>()
|
||||
|
||||
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<String>()
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user