This commit was merged in pull request #20.
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