fix: add bounds checking and content splitting for multi-page songs (Closes #17)
The PDF renderer now tracks y-position against the bottom margin and splits song sections across pages when content exceeds available space. Footer elements (notes, bottom metadata, reference footer) render on the last page with proper space reservation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
renderSongPage(
|
||||
cb, chordLyricRenderer, fontMetrics, config,
|
||||
pageContent.song, pageContent.pageIndex,
|
||||
contentTop, leftMargin, contentWidth
|
||||
contentTop, leftMargin, contentWidth, marginBottom
|
||||
)
|
||||
}
|
||||
is PageContent.FillerImage -> {
|
||||
@@ -91,6 +91,166 @@ class PdfBookRenderer : BookRenderer {
|
||||
document.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the index of the first section that should be rendered on page 1.
|
||||
* All sections before this index render on page 0; sections from this index
|
||||
* onward render on page 1.
|
||||
*
|
||||
* If all sections fit on page 0, returns song.sections.size (i.e., nothing on page 1).
|
||||
*/
|
||||
private fun computeSplitIndex(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float,
|
||||
availableHeightOnPage0: Float
|
||||
): Int {
|
||||
var consumed = 0f
|
||||
|
||||
// Header: title
|
||||
consumed += config.fonts.title.size * 1.5f
|
||||
|
||||
// Top metadata
|
||||
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
|
||||
if (!renderMetaAtBottom) {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
consumed += config.fonts.metadata.size * 1.8f * metaParts.size
|
||||
}
|
||||
}
|
||||
|
||||
// Key/capo
|
||||
if (song.key != null || song.capo != null) {
|
||||
consumed += config.fonts.metadata.size * 1.8f
|
||||
}
|
||||
|
||||
consumed += 4f // gap before sections
|
||||
|
||||
for ((index, section) in song.sections.withIndex()) {
|
||||
val sectionHeight = calculateSectionHeight(section, fontMetrics, config, contentWidth)
|
||||
if (consumed + sectionHeight > availableHeightOnPage0) {
|
||||
// This section doesn't fit on page 0
|
||||
return index
|
||||
}
|
||||
consumed += sectionHeight
|
||||
// Add verse spacing
|
||||
consumed += config.layout.verseSpacing / 0.3528f
|
||||
}
|
||||
|
||||
return song.sections.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the height in PDF points that a section will consume when rendered.
|
||||
*/
|
||||
private fun calculateSectionHeight(
|
||||
section: SongSection,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float {
|
||||
var height = 0f
|
||||
val metaSize = config.fonts.metadata.size
|
||||
|
||||
// Section label
|
||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
||||
val labelText = section.label ?: when (section.type) {
|
||||
SectionType.CHORUS -> "Refrain"
|
||||
SectionType.REPEAT -> "Wiederholung"
|
||||
else -> null
|
||||
}
|
||||
if (labelText != null) {
|
||||
height += metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
|
||||
// Empty chorus (reference)
|
||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
||||
height += metaSize * 1.8f
|
||||
return height
|
||||
}
|
||||
|
||||
// Repeat start marker (contributes no extra height - drawn at current y)
|
||||
// Lines
|
||||
val chordSize = config.fonts.chords.size
|
||||
val lyricSize = config.fonts.lyrics.size
|
||||
val chordLineHeight = chordSize * 1.2f
|
||||
val lyricLineHeight = lyricSize * 1.2f
|
||||
val chordLyricGap = config.layout.chordLineSpacing / 0.3528f
|
||||
|
||||
for (line in section.lines) {
|
||||
if (line.imagePath != null) {
|
||||
// Inline image: 40mm max height + gaps
|
||||
val maxImageHeight = 40f / 0.3528f
|
||||
height += maxImageHeight + 6f
|
||||
} else {
|
||||
val hasChords = line.segments.any { it.chord != null }
|
||||
var lineHeight = lyricLineHeight
|
||||
if (hasChords) {
|
||||
lineHeight += chordLineHeight + chordLyricGap
|
||||
}
|
||||
height += lineHeight + 1f // 1pt gap between lines
|
||||
}
|
||||
}
|
||||
|
||||
// Repeat end marker
|
||||
if (section.type == SectionType.REPEAT) {
|
||||
height += metaSize * 1.5f
|
||||
}
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the space (in PDF points) that must be reserved at the bottom of
|
||||
* the last page of a song for notes, bottom-position metadata, and reference footer.
|
||||
*/
|
||||
private fun calculateFooterReservation(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float {
|
||||
var reserved = 0f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
|
||||
// Notes
|
||||
if (song.notes.isNotEmpty()) {
|
||||
reserved += 4f // gap before notes
|
||||
val noteLineHeight = metaSize * 1.5f
|
||||
for ((idx, note) in song.notes.withIndex()) {
|
||||
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
|
||||
reserved += noteLineHeight * wrappedLines.size
|
||||
if (idx < song.notes.size - 1) {
|
||||
reserved += noteLineHeight * 0.3f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom metadata
|
||||
if (config.layout.metadataPosition == "bottom") {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
reserved += 4f
|
||||
for (metaLine in metaParts) {
|
||||
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
|
||||
reserved += metaSize * 1.5f * wrappedLines.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference footer
|
||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||
val lineHeight = metaSize * 1.4f
|
||||
reserved += lineHeight * 2 // two rows (headers + numbers)
|
||||
reserved += lineHeight * 1.5f // separator line gap
|
||||
reserved += lineHeight * 0.5f // bottom gap
|
||||
}
|
||||
|
||||
return reserved
|
||||
}
|
||||
|
||||
private fun renderSongPage(
|
||||
cb: PdfContentByte,
|
||||
chordLyricRenderer: ChordLyricRenderer,
|
||||
@@ -100,12 +260,28 @@ class PdfBookRenderer : BookRenderer {
|
||||
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
|
||||
contentTop: Float,
|
||||
leftMargin: Float,
|
||||
contentWidth: Float
|
||||
contentWidth: Float,
|
||||
bottomMargin: Float
|
||||
) {
|
||||
var y = contentTop
|
||||
|
||||
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
|
||||
|
||||
// Calculate the footer reservation for the last page
|
||||
val footerReservation = calculateFooterReservation(song, fontMetrics, config, contentWidth)
|
||||
|
||||
// Compute the split index to determine which sections go on which page.
|
||||
// Page 0 gets sections 0..<splitIndex, page 1 gets sections splitIndex..<size.
|
||||
// Footer space is reserved on the last page only.
|
||||
val availableOnPage0 = contentTop - bottomMargin -
|
||||
(if (song.sections.size > 0) footerReservation else 0f)
|
||||
val splitIndex = computeSplitIndex(song, fontMetrics, config, contentWidth, availableOnPage0)
|
||||
val isTwoPageSong = splitIndex < song.sections.size
|
||||
val isLastPage = if (isTwoPageSong) pageIndex == 1 else pageIndex == 0
|
||||
|
||||
// Bottom boundary for content on this page
|
||||
val yMin = bottomMargin + (if (isLastPage) footerReservation else 0f)
|
||||
|
||||
if (pageIndex == 0) {
|
||||
// Render title
|
||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||
@@ -125,6 +301,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (metaLine in metaParts) {
|
||||
if (y - metaSize * 1.8f < yMin) break
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
@@ -143,24 +320,31 @@ class PdfBookRenderer : BookRenderer {
|
||||
if (infoParts.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(infoParts.joinToString(" | "))
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
if (y - metaSize * 1.8f >= yMin) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(infoParts.joinToString(" | "))
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
}
|
||||
}
|
||||
|
||||
y -= 4f // gap before sections
|
||||
}
|
||||
|
||||
// Determine which sections to render on this page
|
||||
// For simplicity in this implementation, render all sections on pageIndex 0
|
||||
// A more sophisticated implementation would split sections across pages
|
||||
val sections = if (pageIndex == 0) song.sections else emptyList()
|
||||
val sections = if (pageIndex == 0) {
|
||||
song.sections.subList(0, splitIndex)
|
||||
} else {
|
||||
song.sections.subList(splitIndex, song.sections.size)
|
||||
}
|
||||
|
||||
for (section in sections) {
|
||||
// Safety check: stop rendering if we've gone below the boundary
|
||||
if (y < yMin) break
|
||||
|
||||
// Section label
|
||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
||||
val labelText = section.label ?: when (section.type) {
|
||||
@@ -171,6 +355,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
if (labelText != null) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
if (y - metaSize * 1.5f < yMin) break
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
@@ -185,6 +370,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
if (y - metaSize * 1.8f < yMin) break
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
@@ -209,6 +395,7 @@ class PdfBookRenderer : BookRenderer {
|
||||
|
||||
// Render lines
|
||||
for (line in section.lines) {
|
||||
if (y < yMin) break
|
||||
val imgPath = line.imagePath
|
||||
if (imgPath != null) {
|
||||
// Render inline image
|
||||
@@ -223,21 +410,23 @@ class PdfBookRenderer : BookRenderer {
|
||||
if (section.type == SectionType.REPEAT) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(":\u2502")
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
if (y - metaSize * 1.5f >= yMin) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(":\u2502")
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
|
||||
// Verse spacing
|
||||
y -= config.layout.verseSpacing / 0.3528f
|
||||
}
|
||||
|
||||
// Render notes at the bottom (with word-wrap for multi-paragraph notes)
|
||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
||||
// Render notes on the last page
|
||||
if (isLastPage && song.notes.isNotEmpty()) {
|
||||
y -= 4f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
@@ -261,8 +450,8 @@ class PdfBookRenderer : BookRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Render metadata at bottom of song page (if configured)
|
||||
if (renderMetaAtBottom && pageIndex == 0) {
|
||||
// Render metadata at bottom of song page (if configured) - on the last page only
|
||||
if (renderMetaAtBottom && isLastPage) {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
y -= 4f
|
||||
@@ -284,15 +473,12 @@ class PdfBookRenderer : BookRenderer {
|
||||
}
|
||||
|
||||
// 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
|
||||
if (isLastPage) {
|
||||
renderReferenceFooter(
|
||||
cb, fontMetrics, config, song,
|
||||
leftMargin, contentWidth,
|
||||
config.layout.margins.bottom / 0.3528f
|
||||
)
|
||||
}
|
||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty() && isLastPage) {
|
||||
renderReferenceFooter(
|
||||
cb, fontMetrics, config, song,
|
||||
leftMargin, contentWidth,
|
||||
bottomMargin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -417,4 +417,213 @@ class PdfBookRendererTest {
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
// --- Content splitting tests ---
|
||||
|
||||
private fun createLongSong(title: String = "Long Song"): Song {
|
||||
// Create a song with many sections that will exceed one A5 page
|
||||
val sections = (1..20).map { i ->
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse $i",
|
||||
lines = (1..4).map {
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "Am", text = "Some text with chords "),
|
||||
LineSegment(chord = "G", text = "and more text here")
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return Song(title = title, sections = sections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render splits content across pages for two-page song`() {
|
||||
val song = createLongSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
val bytes = baos.toByteArray()
|
||||
val header = String(bytes.sliceArray(0..4))
|
||||
header shouldBe "%PDF-"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render does not overflow below bottom margin for very long song`() {
|
||||
// Create an extremely long song
|
||||
val sections = (1..40).map { i ->
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse $i",
|
||||
lines = (1..6).map {
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "C", text = "A long line of text that should be rendered properly "),
|
||||
LineSegment(chord = "G", text = "with chords above each segment")
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val song = Song(title = "Very Long Song", sections = sections)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render places metadata at bottom of last page for two-page song`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(metadataPosition = "bottom")
|
||||
)
|
||||
val song = createLongSong().copy(
|
||||
composer = "Bach",
|
||||
lyricist = "Goethe"
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render places notes on last page of two-page song`() {
|
||||
val song = createLongSong().copy(
|
||||
notes = listOf("This is a note that should appear on the last page")
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render places reference footer on last page of two-page song`() {
|
||||
val config = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
|
||||
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
|
||||
)
|
||||
)
|
||||
val song = createLongSong().copy(
|
||||
references = mapOf("mo" to 42, "pl" to 17)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles short song that fits on one page without splitting`() {
|
||||
// A simple short song should still work correctly after split logic is added
|
||||
val song = Song(
|
||||
title = "Short Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(chord = "Am", text = "One line")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render two-page song with bottom metadata and references`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(
|
||||
metadataPosition = "bottom",
|
||||
metadataLabels = "german"
|
||||
),
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO")
|
||||
)
|
||||
)
|
||||
val song = createLongSong().copy(
|
||||
composer = "Bach",
|
||||
lyricist = "Goethe",
|
||||
notes = listOf("Play softly", "Repeat last verse"),
|
||||
references = mapOf("mo" to 55)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user