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 fc3bca9..7e97502 100644 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt @@ -43,15 +43,21 @@ class MeasurementEngine( // Lines in section for (line in section.lines) { - val hasChords = line.segments.any { it.chord != null } - val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size) - if (hasChords) { - val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size) - heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight + if (line.imagePath != null) { + // Inline image: estimate height as 40mm (default image block height) + heightMm += 40f + heightMm += 2f // gap around image } else { - heightMm += lyricHeight + val hasChords = line.segments.any { it.chord != null } + val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size) + if (hasChords) { + val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size) + heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight + } else { + heightMm += lyricHeight + } + heightMm += 0.35f // ~1pt gap between lines } - heightMm += 0.35f // ~1pt gap between lines } // Verse spacing 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 3393d2e..0eab196 100644 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt @@ -324,4 +324,40 @@ class MeasurementEngineTest { // Should be the same since no reference books are configured 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 + } } diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt index 6dabb6c..537336c 100644 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt @@ -23,7 +23,10 @@ enum class SectionType { VERSE, CHORUS, BRIDGE, REPEAT } -data class SongLine(val segments: List) +data class SongLine( + val segments: List = emptyList(), + val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored) +) data class LineSegment( val chord: String? = null, // null = no chord above this segment diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt index 995b931..83c84da 100644 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt @@ -129,6 +129,13 @@ object ChordProParser { "end_of_repeat", "eor" -> { 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 } diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt index 7a2a292..d50c8c1 100644 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt @@ -577,4 +577,55 @@ class ChordProParserTest { 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" + } } 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 e35d141..e8c5f38 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 @@ -205,8 +205,14 @@ class PdfBookRenderer : BookRenderer { // Render lines for (line in section.lines) { - val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth) - y -= height + 1f // 1pt gap between 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) + y -= height + 1f // 1pt gap between lines + } } // End repeat marker @@ -446,6 +452,37 @@ class PdfBookRenderer : BookRenderer { 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) { try { val img = Image.getInstance(imagePath)