feat: support inline images within song pages (Closes #2) #13

Merged
shahondin1624 merged 1 commits from feature/issue-2-support-inline-images-within-songs into main 2026-03-17 09:45:57 +01:00
6 changed files with 150 additions and 10 deletions

View File

@@ -43,15 +43,21 @@ class MeasurementEngine(
// Lines in section // Lines in section
for (line in section.lines) { for (line in section.lines) {
val hasChords = line.segments.any { it.chord != null } if (line.imagePath != null) {
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size) // Inline image: estimate height as 40mm (default image block height)
if (hasChords) { heightMm += 40f
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size) heightMm += 2f // gap around image
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
} else { } 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 // Verse spacing

View File

@@ -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
}
} }

View File

@@ -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

View File

@@ -129,6 +129,13 @@ 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" -> { "start_of_notes", "son" -> {
inNotesBlock = true inNotesBlock = true
} }

View File

@@ -577,4 +577,55 @@ class ChordProParserTest {
song.notes[1] shouldBe "Paragraph two." song.notes[1] shouldBe "Paragraph two."
song.notes[2] shouldBe "Paragraph three." 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"
}
} }

View File

@@ -205,8 +205,14 @@ class PdfBookRenderer : BookRenderer {
// Render lines // Render lines
for (line in section.lines) { for (line in section.lines) {
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth) val imgPath = line.imagePath
y -= height + 1f // 1pt gap between lines 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 // End repeat marker
@@ -446,6 +452,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)