feat: support inline images within song pages (Closes #2) #13
@@ -43,6 +43,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,6 +58,7 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,9 +205,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) {
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user