Compare commits
3 Commits
62aa32c13d
...
b339c10ca0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b339c10ca0 | ||
|
|
8dca7d7131 | ||
|
|
8c92c7d78b |
@@ -15,9 +15,16 @@ class MeasurementEngine(
|
|||||||
// Title height
|
// Title height
|
||||||
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
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) {
|
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
|
// Key/capo line
|
||||||
@@ -43,6 +50,11 @@ class MeasurementEngine(
|
|||||||
|
|
||||||
// Lines in section
|
// Lines in section
|
||||||
for (line in section.lines) {
|
for (line in section.lines) {
|
||||||
|
if (line.imagePath != null) {
|
||||||
|
// Inline image: estimate height as 40mm (default image block height)
|
||||||
|
heightMm += 40f
|
||||||
|
heightMm += 2f // gap around image
|
||||||
|
} else {
|
||||||
val hasChords = line.segments.any { it.chord != null }
|
val hasChords = line.segments.any { it.chord != null }
|
||||||
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
||||||
if (hasChords) {
|
if (hasChords) {
|
||||||
@@ -53,16 +65,29 @@ class MeasurementEngine(
|
|||||||
}
|
}
|
||||||
heightMm += 0.35f // ~1pt gap between lines
|
heightMm += 0.35f // ~1pt gap between lines
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Verse spacing
|
// Verse spacing
|
||||||
heightMm += config.layout.verseSpacing
|
heightMm += config.layout.verseSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes at bottom
|
// Notes at bottom (with word-wrap estimation for multi-paragraph notes)
|
||||||
if (song.notes.isNotEmpty()) {
|
if (song.notes.isNotEmpty()) {
|
||||||
heightMm += 1.5f // gap
|
heightMm += 1.5f // gap before notes
|
||||||
for (note in song.notes) {
|
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
// A5 content width in mm = 148 - inner margin - outer margin
|
||||||
|
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
|
||||||
|
|
||||||
|
for ((idx, note) in song.notes.withIndex()) {
|
||||||
|
// Estimate how many wrapped lines this note paragraph needs
|
||||||
|
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
|
||||||
|
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
|
||||||
|
heightMm += metaLineHeight * estimatedLines
|
||||||
|
|
||||||
|
// Paragraph spacing between note paragraphs
|
||||||
|
if (idx < song.notes.size - 1) {
|
||||||
|
heightMm += metaLineHeight * 0.3f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,4 +324,40 @@ class MeasurementEngineTest {
|
|||||||
// Should be the same since no reference books are configured
|
// Should be the same since no reference books are configured
|
||||||
heightWith shouldBe heightWithout
|
heightWith shouldBe heightWithout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inline image adds significant height`() {
|
||||||
|
val songWithImage = Song(
|
||||||
|
title = "With Image",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(
|
||||||
|
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||||
|
SongLine(imagePath = "images/test.png"),
|
||||||
|
SongLine(listOf(LineSegment(text = "Line after")))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutImage = Song(
|
||||||
|
title = "No Image",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(
|
||||||
|
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||||
|
SongLine(listOf(LineSegment(text = "Line after")))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val heightWith = engine.measure(songWithImage).totalHeightMm
|
||||||
|
val heightWithout = engine.measure(songWithoutImage).totalHeightMm
|
||||||
|
|
||||||
|
// Inline image adds ~42mm (40mm image + 2mm gap)
|
||||||
|
val diff = heightWith - heightWithout
|
||||||
|
diff shouldBeGreaterThan 30f // should be substantial
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ data class LayoutConfig(
|
|||||||
val margins: Margins = Margins(),
|
val margins: Margins = Margins(),
|
||||||
val chordLineSpacing: Float = 3f, // mm
|
val chordLineSpacing: Float = 3f, // mm
|
||||||
val verseSpacing: Float = 4f, // 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(
|
data class Margins(
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ enum class SectionType {
|
|||||||
VERSE, CHORUS, BRIDGE, REPEAT
|
VERSE, CHORUS, BRIDGE, REPEAT
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SongLine(val segments: List<LineSegment>)
|
data class SongLine(
|
||||||
|
val segments: List<LineSegment> = emptyList(),
|
||||||
|
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
|
||||||
|
)
|
||||||
|
|
||||||
data class LineSegment(
|
data class LineSegment(
|
||||||
val chord: String? = null, // null = no chord above this segment
|
val chord: String? = null, // null = no chord above this segment
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ object ChordProParser {
|
|||||||
var currentLabel: String? = null
|
var currentLabel: String? = null
|
||||||
var currentLines = mutableListOf<SongLine>()
|
var currentLines = mutableListOf<SongLine>()
|
||||||
|
|
||||||
|
// Notes block state
|
||||||
|
var inNotesBlock = false
|
||||||
|
var currentNoteParagraph = StringBuilder()
|
||||||
|
|
||||||
|
fun flushNoteParagraph() {
|
||||||
|
if (currentNoteParagraph.isNotEmpty()) {
|
||||||
|
notes.add(currentNoteParagraph.toString().trim())
|
||||||
|
currentNoteParagraph = StringBuilder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun flushSection() {
|
fun flushSection() {
|
||||||
if (currentType != null) {
|
if (currentType != null) {
|
||||||
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
||||||
@@ -37,6 +48,27 @@ object ChordProParser {
|
|||||||
for (rawLine in lines) {
|
for (rawLine in lines) {
|
||||||
val line = rawLine.trimEnd()
|
val line = rawLine.trimEnd()
|
||||||
|
|
||||||
|
// Inside a notes block: collect lines as paragraphs
|
||||||
|
if (inNotesBlock) {
|
||||||
|
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||||
|
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
|
||||||
|
if (inner == "end_of_notes" || inner == "eon") {
|
||||||
|
flushNoteParagraph()
|
||||||
|
inNotesBlock = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.isBlank()) {
|
||||||
|
flushNoteParagraph()
|
||||||
|
} else {
|
||||||
|
if (currentNoteParagraph.isNotEmpty()) {
|
||||||
|
currentNoteParagraph.append(" ")
|
||||||
|
}
|
||||||
|
currentNoteParagraph.append(line.trim())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Skip comments
|
// Skip comments
|
||||||
if (line.trimStart().startsWith("#")) continue
|
if (line.trimStart().startsWith("#")) continue
|
||||||
|
|
||||||
@@ -97,6 +129,21 @@ object ChordProParser {
|
|||||||
"end_of_repeat", "eor" -> {
|
"end_of_repeat", "eor" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
}
|
}
|
||||||
|
"image" -> if (value != null) {
|
||||||
|
// Inline image within a song section
|
||||||
|
if (currentType == null) {
|
||||||
|
currentType = SectionType.VERSE
|
||||||
|
}
|
||||||
|
currentLines.add(SongLine(imagePath = value.trim()))
|
||||||
|
}
|
||||||
|
"start_of_notes", "son" -> {
|
||||||
|
inNotesBlock = true
|
||||||
|
}
|
||||||
|
"end_of_notes", "eon" -> {
|
||||||
|
// Should have been handled in the notes block above
|
||||||
|
flushNoteParagraph()
|
||||||
|
inNotesBlock = false
|
||||||
|
}
|
||||||
"chorus" -> {
|
"chorus" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
sections.add(SongSection(type = SectionType.CHORUS))
|
sections.add(SongSection(type = SectionType.CHORUS))
|
||||||
|
|||||||
@@ -485,4 +485,147 @@ class ChordProParserTest {
|
|||||||
line.segments[2].chord shouldBe "G"
|
line.segments[2].chord shouldBe "G"
|
||||||
line.segments[2].text shouldBe "End"
|
line.segments[2].text shouldBe "End"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with multiple paragraphs`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
First paragraph of the notes.
|
||||||
|
It continues on the next line.
|
||||||
|
|
||||||
|
Second paragraph with different content.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 2
|
||||||
|
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
|
||||||
|
song.notes[1] shouldBe "Second paragraph with different content."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with single paragraph`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
A single note paragraph.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 1
|
||||||
|
song.notes[0] shouldBe "A single note paragraph."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with short directives son eon`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{son}
|
||||||
|
Short form notes.
|
||||||
|
{eon}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 1
|
||||||
|
song.notes[0] shouldBe "Short form notes."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notes block and single note directives combine`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{note: Single line note}
|
||||||
|
{start_of_notes}
|
||||||
|
Block note paragraph.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 2
|
||||||
|
song.notes[0] shouldBe "Single line note"
|
||||||
|
song.notes[1] shouldBe "Block note paragraph."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with three paragraphs`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
Paragraph one.
|
||||||
|
|
||||||
|
Paragraph two.
|
||||||
|
|
||||||
|
Paragraph three.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 3
|
||||||
|
song.notes[0] shouldBe "Paragraph one."
|
||||||
|
song.notes[1] shouldBe "Paragraph two."
|
||||||
|
song.notes[2] shouldBe "Paragraph three."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse image directive within song section`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_verse}
|
||||||
|
[Am]Hello world
|
||||||
|
{image: images/drawing.png}
|
||||||
|
[C]Goodbye world
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections shouldHaveSize 1
|
||||||
|
song.sections[0].lines shouldHaveSize 3
|
||||||
|
song.sections[0].lines[0].segments[0].chord shouldBe "Am"
|
||||||
|
song.sections[0].lines[1].imagePath shouldBe "images/drawing.png"
|
||||||
|
song.sections[0].lines[1].segments.shouldBeEmpty()
|
||||||
|
song.sections[0].lines[2].segments[0].chord shouldBe "C"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse image directive outside section creates implicit verse`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{image: images/landscape.jpg}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections shouldHaveSize 1
|
||||||
|
song.sections[0].type shouldBe SectionType.VERSE
|
||||||
|
song.sections[0].lines shouldHaveSize 1
|
||||||
|
song.sections[0].lines[0].imagePath shouldBe "images/landscape.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse multiple image directives`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_verse}
|
||||||
|
{image: img1.png}
|
||||||
|
Some text
|
||||||
|
{image: img2.png}
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections[0].lines shouldHaveSize 3
|
||||||
|
song.sections[0].lines[0].imagePath shouldBe "img1.png"
|
||||||
|
song.sections[0].lines[0].segments.shouldBeEmpty()
|
||||||
|
song.sections[0].lines[1].imagePath.shouldBeNull()
|
||||||
|
song.sections[0].lines[1].segments[0].text shouldBe "Some text"
|
||||||
|
song.sections[0].lines[2].imagePath shouldBe "img2.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,31 @@ class ConfigParserTest {
|
|||||||
config.toc.highlightColumn.shouldBeNull()
|
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
|
@Test
|
||||||
fun `parse config ignores unknown properties`() {
|
fun `parse config ignores unknown properties`() {
|
||||||
val yaml = """
|
val yaml = """
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
) {
|
) {
|
||||||
var y = contentTop
|
var y = contentTop
|
||||||
|
|
||||||
|
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
|
||||||
|
|
||||||
if (pageIndex == 0) {
|
if (pageIndex == 0) {
|
||||||
// Render title
|
// Render title
|
||||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||||
@@ -116,21 +118,23 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
cb.endText()
|
cb.endText()
|
||||||
y -= titleSize * 1.5f
|
y -= titleSize * 1.5f
|
||||||
|
|
||||||
// Render metadata line (composer/lyricist)
|
// Render metadata line (composer/lyricist) - at top position only
|
||||||
val metaParts = mutableListOf<String>()
|
if (!renderMetaAtBottom) {
|
||||||
song.composer?.let { metaParts.add("M: $it") }
|
val metaParts = buildMetadataLines(song, config)
|
||||||
song.lyricist?.let { metaParts.add("T: $it") }
|
|
||||||
if (metaParts.isNotEmpty()) {
|
if (metaParts.isNotEmpty()) {
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
|
for (metaLine in metaParts) {
|
||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setColorFill(Color.GRAY)
|
cb.setColorFill(Color.GRAY)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||||
cb.showText(metaParts.joinToString(" / "))
|
cb.showText(metaLine)
|
||||||
cb.endText()
|
cb.endText()
|
||||||
y -= metaSize * 1.8f
|
y -= metaSize * 1.8f
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render key and capo
|
// Render key and capo
|
||||||
val infoParts = mutableListOf<String>()
|
val infoParts = mutableListOf<String>()
|
||||||
@@ -205,9 +209,15 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
|
|
||||||
// Render lines
|
// Render lines
|
||||||
for (line in section.lines) {
|
for (line in section.lines) {
|
||||||
|
val imgPath = line.imagePath
|
||||||
|
if (imgPath != null) {
|
||||||
|
// Render inline image
|
||||||
|
y -= renderInlineImage(cb, imgPath, leftMargin, y, contentWidth)
|
||||||
|
} else {
|
||||||
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
||||||
y -= height + 1f // 1pt gap between lines
|
y -= height + 1f // 1pt gap between lines
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// End repeat marker
|
// End repeat marker
|
||||||
if (section.type == SectionType.REPEAT) {
|
if (section.type == SectionType.REPEAT) {
|
||||||
@@ -226,21 +236,52 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
y -= config.layout.verseSpacing / 0.3528f
|
y -= config.layout.verseSpacing / 0.3528f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render notes at the bottom
|
// Render notes at the bottom (with word-wrap for multi-paragraph notes)
|
||||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
||||||
y -= 4f
|
y -= 4f
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
for (note in song.notes) {
|
val noteLineHeight = metaSize * 1.5f
|
||||||
|
|
||||||
|
for ((idx, note) in song.notes.withIndex()) {
|
||||||
|
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
|
||||||
|
for (wrappedLine in wrappedLines) {
|
||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setColorFill(Color.GRAY)
|
cb.setColorFill(Color.GRAY)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||||
cb.showText(note)
|
cb.showText(wrappedLine)
|
||||||
|
cb.endText()
|
||||||
|
y -= noteLineHeight
|
||||||
|
}
|
||||||
|
// Add paragraph spacing between note paragraphs
|
||||||
|
if (idx < song.notes.size - 1) {
|
||||||
|
y -= noteLineHeight * 0.3f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
cb.endText()
|
||||||
y -= metaSize * 1.5f
|
y -= metaSize * 1.5f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render reference book footer on the last page of the song
|
// Render reference book footer on the last page of the song
|
||||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||||
@@ -255,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
|
* Renders reference book abbreviations and page numbers as a footer row
|
||||||
* at the bottom of the song page, above the page number.
|
* at the bottom of the song page, above the page number.
|
||||||
@@ -437,6 +507,37 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an inline image within a song page at the given position.
|
||||||
|
* Returns the total height consumed in PDF points.
|
||||||
|
*/
|
||||||
|
private fun renderInlineImage(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
imagePath: String,
|
||||||
|
leftMargin: Float,
|
||||||
|
y: Float,
|
||||||
|
contentWidth: Float
|
||||||
|
): Float {
|
||||||
|
try {
|
||||||
|
val img = Image.getInstance(imagePath)
|
||||||
|
// Scale to fit within content width, max height 40mm (~113 points)
|
||||||
|
val maxHeight = 40f / 0.3528f // 40mm in points
|
||||||
|
img.scaleToFit(contentWidth * 0.8f, maxHeight)
|
||||||
|
|
||||||
|
// Center horizontally
|
||||||
|
val imgX = leftMargin + (contentWidth - img.scaledWidth) / 2
|
||||||
|
val imgY = y - img.scaledHeight - 3f // 3pt gap above
|
||||||
|
|
||||||
|
img.setAbsolutePosition(imgX, imgY)
|
||||||
|
cb.addImage(img)
|
||||||
|
|
||||||
|
return img.scaledHeight + 6f // image height + gaps above/below
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// If image can't be loaded, consume minimal space
|
||||||
|
return 5f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
||||||
try {
|
try {
|
||||||
val img = Image.getInstance(imagePath)
|
val img = Image.getInstance(imagePath)
|
||||||
|
|||||||
Reference in New Issue
Block a user