Compare commits
2 Commits
79688be51e
...
0139327034
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0139327034 | ||
|
|
ba035159f7 |
@@ -66,6 +66,13 @@ class MeasurementEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference book footer: reserve space for abbreviation row + page number row + separator line
|
||||||
|
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||||
|
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size)
|
||||||
|
heightMm += metaLineHeight * 1.4f * 2 // two rows (headers + numbers)
|
||||||
|
heightMm += metaLineHeight * 0.5f // separator line gap
|
||||||
|
}
|
||||||
|
|
||||||
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
||||||
return MeasuredSong(song, heightMm, pageCount)
|
return MeasuredSong(song, heightMm, pageCount)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,4 +258,70 @@ class MeasurementEngineTest {
|
|||||||
|
|
||||||
labeledHeight shouldBeGreaterThan unlabeledHeight
|
labeledHeight shouldBeGreaterThan unlabeledHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `references add footer height when reference books configured`() {
|
||||||
|
val configWithRefs = BookConfig(
|
||||||
|
referenceBooks = listOf(
|
||||||
|
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
|
||||||
|
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val engineWithRefs = MeasurementEngine(fontMetrics, configWithRefs)
|
||||||
|
|
||||||
|
val songWithRefs = Song(
|
||||||
|
title = "With Refs",
|
||||||
|
references = mapOf("mo" to 42, "pl" to 17),
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutRefs = Song(
|
||||||
|
title = "No Refs",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val heightWith = engineWithRefs.measure(songWithRefs).totalHeightMm
|
||||||
|
val heightWithout = engineWithRefs.measure(songWithoutRefs).totalHeightMm
|
||||||
|
|
||||||
|
heightWith shouldBeGreaterThan heightWithout
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `references do not add height when no reference books configured`() {
|
||||||
|
val songWithRefs = Song(
|
||||||
|
title = "With Refs",
|
||||||
|
references = mapOf("mo" to 42),
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutRefs = Song(
|
||||||
|
title = "No Refs",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default config has no reference books
|
||||||
|
val heightWith = engine.measure(songWithRefs).totalHeightMm
|
||||||
|
val heightWithout = engine.measure(songWithoutRefs).totalHeightMm
|
||||||
|
|
||||||
|
// Should be the same since no reference books are configured
|
||||||
|
heightWith shouldBe heightWithout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ data class BookConfig(
|
|||||||
val images: ImagesConfig = ImagesConfig(),
|
val images: ImagesConfig = ImagesConfig(),
|
||||||
val referenceBooks: List<ReferenceBook> = emptyList(),
|
val referenceBooks: List<ReferenceBook> = emptyList(),
|
||||||
val output: OutputConfig = OutputConfig(),
|
val output: OutputConfig = OutputConfig(),
|
||||||
val foreword: ForewordConfig? = null
|
val foreword: ForewordConfig? = null,
|
||||||
|
val toc: TocConfig = TocConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TocConfig(
|
||||||
|
val highlightColumn: String? = null // abbreviation of the column to highlight (e.g. "CL")
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ForewordConfig(
|
data class ForewordConfig(
|
||||||
|
|||||||
@@ -192,6 +192,28 @@ class ConfigParserTest {
|
|||||||
config.foreword.shouldBeNull()
|
config.foreword.shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with toc highlight column`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
toc:
|
||||||
|
highlight_column: "CL"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.toc.highlightColumn shouldBe "CL"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config without toc section uses defaults`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.toc.highlightColumn.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parse config ignores unknown properties`() {
|
fun `parse config ignores unknown properties`() {
|
||||||
val yaml = """
|
val yaml = """
|
||||||
|
|||||||
@@ -241,6 +241,87 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
y -= metaSize * 1.5f
|
y -= metaSize * 1.5f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders reference book abbreviations and page numbers as a footer row
|
||||||
|
* at the bottom of the song page, above the page number.
|
||||||
|
*/
|
||||||
|
private fun renderReferenceFooter(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
fontMetrics: PdfFontMetrics,
|
||||||
|
config: BookConfig,
|
||||||
|
song: Song,
|
||||||
|
leftMargin: Float,
|
||||||
|
contentWidth: Float,
|
||||||
|
bottomMargin: Float
|
||||||
|
) {
|
||||||
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
|
val metaSize = config.fonts.metadata.size
|
||||||
|
val lineHeight = metaSize * 1.4f
|
||||||
|
|
||||||
|
// Position: just above the page number area
|
||||||
|
val footerY = bottomMargin + lineHeight * 0.5f
|
||||||
|
|
||||||
|
// Map book IDs to abbreviations
|
||||||
|
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
|
||||||
|
val books = config.referenceBooks
|
||||||
|
|
||||||
|
// Calculate column widths: evenly distribute across content width
|
||||||
|
val colWidth = contentWidth / books.size
|
||||||
|
|
||||||
|
// Row 1: Abbreviation headers
|
||||||
|
for ((i, book) in books.withIndex()) {
|
||||||
|
val x = leftMargin + i * colWidth
|
||||||
|
val abbr = book.abbreviation
|
||||||
|
val textWidth = metaFont.getWidthPoint(abbr, metaSize)
|
||||||
|
// Center text in column
|
||||||
|
val textX = x + (colWidth - textWidth) / 2
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(textX, footerY + lineHeight)
|
||||||
|
cb.showText(abbr)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: Page numbers
|
||||||
|
for ((i, book) in books.withIndex()) {
|
||||||
|
val x = leftMargin + i * colWidth
|
||||||
|
val pageNum = song.references[book.id]
|
||||||
|
if (pageNum != null) {
|
||||||
|
val pageText = pageNum.toString()
|
||||||
|
val textWidth = metaFont.getWidthPoint(pageText, metaSize)
|
||||||
|
val textX = x + (colWidth - textWidth) / 2
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(textX, footerY)
|
||||||
|
cb.showText(pageText)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a thin line above the footer
|
||||||
|
cb.setLineWidth(0.3f)
|
||||||
|
cb.setColorStroke(Color.LIGHT_GRAY)
|
||||||
|
cb.moveTo(leftMargin, footerY + lineHeight * 1.5f)
|
||||||
|
cb.lineTo(leftMargin + contentWidth, footerY + lineHeight * 1.5f)
|
||||||
|
cb.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderForewordPage(
|
private fun renderForewordPage(
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package de.pfadfinder.songbook.renderer.pdf
|
|||||||
import com.lowagie.text.*
|
import com.lowagie.text.*
|
||||||
import com.lowagie.text.pdf.*
|
import com.lowagie.text.pdf.*
|
||||||
import de.pfadfinder.songbook.model.*
|
import de.pfadfinder.songbook.model.*
|
||||||
|
import java.awt.Color
|
||||||
|
|
||||||
class TocRenderer(
|
class TocRenderer(
|
||||||
private val fontMetrics: PdfFontMetrics,
|
private val fontMetrics: PdfFontMetrics,
|
||||||
private val config: BookConfig
|
private val config: BookConfig
|
||||||
) {
|
) {
|
||||||
|
// Light gray background for the highlighted column
|
||||||
|
private val highlightColor = Color(220, 220, 220)
|
||||||
|
|
||||||
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
||||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
||||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
||||||
@@ -35,12 +39,25 @@ class TocRenderer(
|
|||||||
}
|
}
|
||||||
table.setWidths(widths)
|
table.setWidths(widths)
|
||||||
|
|
||||||
|
// Determine which column index should be highlighted
|
||||||
|
val highlightAbbrev = config.toc.highlightColumn
|
||||||
|
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
|
||||||
|
// Check "Seite" (page) column first - the current book's page number column
|
||||||
|
if (highlightAbbrev == "Seite") {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
val refIndex = refBooks.indexOfFirst { it.abbreviation == highlightAbbrev }
|
||||||
|
if (refIndex >= 0) 2 + refIndex else null
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
// Header row
|
// Header row
|
||||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||||
table.addCell(headerCell("Titel", headerFont))
|
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
||||||
table.addCell(headerCell("Seite", headerFont))
|
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
||||||
for (book in refBooks) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
|
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
|
||||||
}
|
}
|
||||||
table.headerRows = 1
|
table.headerRows = 1
|
||||||
|
|
||||||
@@ -49,31 +66,43 @@ class TocRenderer(
|
|||||||
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
||||||
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
||||||
val font = if (entry.isAlias) aliasFont else entryFont
|
val font = if (entry.isAlias) aliasFont else entryFont
|
||||||
table.addCell(entryCell(entry.title, font))
|
table.addCell(entryCell(entry.title, font, isHighlighted = false))
|
||||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1))
|
||||||
for (book in refBooks) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
val ref = entry.references[book.abbreviation]
|
val ref = entry.references[book.abbreviation]
|
||||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
|
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.add(table)
|
document.add(table)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
cell.borderWidth = 0f
|
cell.borderWidth = 0f
|
||||||
cell.borderWidthBottom = 0.5f
|
cell.borderWidthBottom = 0.5f
|
||||||
cell.paddingBottom = 4f
|
cell.paddingBottom = 4f
|
||||||
|
if (isHighlighted) {
|
||||||
|
cell.backgroundColor = highlightColor
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
private fun entryCell(
|
||||||
|
text: String,
|
||||||
|
font: Font,
|
||||||
|
alignment: Int = Element.ALIGN_LEFT,
|
||||||
|
isHighlighted: Boolean = false
|
||||||
|
): PdfPCell {
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
cell.borderWidth = 0f
|
cell.borderWidth = 0f
|
||||||
cell.horizontalAlignment = alignment
|
cell.horizontalAlignment = alignment
|
||||||
cell.paddingTop = 1f
|
cell.paddingTop = 1f
|
||||||
cell.paddingBottom = 1f
|
cell.paddingBottom = 1f
|
||||||
|
if (isHighlighted) {
|
||||||
|
cell.backgroundColor = highlightColor
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user