feat: anchor footer elements to bottom of page (Closes #31)

Footer elements (notes, Worte/Weise metadata, reference book table) on
song pages now render at fixed positions anchored to the bottom margin
instead of flowing after song content. This ensures consistent vertical
positioning across all pages regardless of content length.

The footer area is computed from bottomMargin + footerReservation and
elements render top-down within it: notes -> metadata -> references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-17 16:20:28 +01:00
parent 543fe66a44
commit 17d306e822

View File

@@ -424,62 +424,70 @@ class PdfBookRenderer : BookRenderer {
y -= config.layout.verseSpacing / 0.3528f
}
// Render notes on the last page
if (isLastPage && song.notes.isNotEmpty()) {
y -= 4f
// Render footer elements (notes, metadata, references) anchored to the bottom of the page.
// Instead of flowing from the current y position after song content, we compute a fixed
// starting Y at the top of the footer area (bottomMargin + footerReservation) and render
// top-down: notes -> metadata -> references. This ensures footer elements always appear
// at the same vertical position regardless of how much song content is on the page.
if (isLastPage && footerReservation > 0f) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
val noteLineHeight = metaSize * 1.5f
// The footer area spans from bottomMargin to bottomMargin + footerReservation.
// Start rendering from the top of this area, flowing downward.
var footerY = bottomMargin + footerReservation
// Render notes (topmost footer element)
if (song.notes.isNotEmpty()) {
footerY -= 4f // gap before notes
val noteLineHeight = metaSize * 1.5f
for ((idx, note) in song.notes.withIndex()) {
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.setTextMatrix(leftMargin, footerY - metaSize)
cb.showText(wrappedLine)
cb.endText()
y -= noteLineHeight
footerY -= noteLineHeight
}
// Add paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
y -= noteLineHeight * 0.3f
footerY -= noteLineHeight * 0.3f
}
}
}
// Render metadata at bottom of song page (if configured) - on the last page only
if (renderMetaAtBottom && isLastPage) {
// Render metadata (Worte/Weise) below notes, if configured at bottom
if (renderMetaAtBottom) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
footerY -= 4f // gap before metadata
for (metaLine in metaParts) {
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.setTextMatrix(leftMargin, footerY - metaSize)
cb.showText(wrappedLine)
cb.endText()
y -= metaSize * 1.5f
footerY -= metaSize * 1.5f
}
}
}
}
// Render reference book footer on the last page of the song
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty() && isLastPage) {
y -= 4f // gap before reference footer
// Render reference book footer (bottommost footer element, just above page number)
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
footerY -= 4f // gap before reference footer
renderReferenceFooter(
cb, fontMetrics, config, song,
leftMargin, y, contentWidth
leftMargin, footerY, contentWidth
)
}
}
}
/**
* Build metadata lines based on configured label style.