feat: add intro page, rotated TOC headers, progress indicator, fix TOC padding (Closes #33)
- Add intro/title page before TOC (configurable via intro.enabled in songbook.yaml) - Rotate reference book column headers 90° in TOC to prevent text truncation - Add progress callback to SongbookPipeline.build() with CLI and GUI integration - Make GUI song loading async with spinner indicator - Fix TOC page padding: use actual rendered page count instead of blindly adding tocPages-1 blank pages - Pre-render TOC to measure actual pages needed (TocRenderer.measurePages) - Account for intro pages in pagination offset and page numbering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ import de.pfadfinder.songbook.model.*
|
|||||||
import de.pfadfinder.songbook.parser.*
|
import de.pfadfinder.songbook.parser.*
|
||||||
import de.pfadfinder.songbook.parser.ForewordParser
|
import de.pfadfinder.songbook.parser.ForewordParser
|
||||||
import de.pfadfinder.songbook.layout.*
|
import de.pfadfinder.songbook.layout.*
|
||||||
import de.pfadfinder.songbook.renderer.pdf.*
|
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer
|
||||||
|
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
|
||||||
|
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -28,9 +30,11 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
* When provided, songs are sorted to match this order instead of using the
|
* When provided, songs are sorted to match this order instead of using the
|
||||||
* config-based sort (alphabetical or manual). Files not in this list are
|
* config-based sort (alphabetical or manual). Files not in this list are
|
||||||
* appended at the end.
|
* appended at the end.
|
||||||
|
* @param onProgress Optional callback invoked with status messages during the build.
|
||||||
*/
|
*/
|
||||||
fun build(customSongOrder: List<String>? = null): BuildResult {
|
fun build(customSongOrder: List<String>? = null, onProgress: ((String) -> Unit)? = null): BuildResult {
|
||||||
// 1. Parse config
|
// 1. Parse config
|
||||||
|
onProgress?.invoke("Konfiguration wird geladen...")
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
if (!configFile.exists()) {
|
if (!configFile.exists()) {
|
||||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||||
@@ -61,12 +65,16 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...")
|
||||||
logger.info { "Found ${songFiles.size} song files" }
|
logger.info { "Found ${songFiles.size} song files" }
|
||||||
|
|
||||||
val songsByFileName = mutableMapOf<String, Song>()
|
val songsByFileName = mutableMapOf<String, Song>()
|
||||||
val allErrors = mutableListOf<ValidationError>()
|
val allErrors = mutableListOf<ValidationError>()
|
||||||
|
|
||||||
for (file in songFiles) {
|
for ((index, file) in songFiles.withIndex()) {
|
||||||
|
if (index > 0 && index % 50 == 0) {
|
||||||
|
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val song = ChordProParser.parseFile(file)
|
val song = ChordProParser.parseFile(file)
|
||||||
val songErrors = Validator.validateSong(song, file.name)
|
val songErrors = Validator.validateSong(song, file.name)
|
||||||
@@ -116,21 +124,39 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Measure songs
|
// 3. Measure songs
|
||||||
|
onProgress?.invoke("Layout wird berechnet...")
|
||||||
val fontMetrics = PdfFontMetrics()
|
val fontMetrics = PdfFontMetrics()
|
||||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||||
|
|
||||||
// 4. Generate TOC and paginate
|
// 4. Generate TOC and paginate
|
||||||
val tocGenerator = TocGenerator(config)
|
val tocGenerator = TocGenerator(config)
|
||||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
val estimatedTocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||||
|
|
||||||
|
// Intro page takes 2 pages (title + blank back) for double-sided printing
|
||||||
|
val introPages = if (config.intro?.enabled == true) 2 else 0
|
||||||
// Foreword always takes 2 pages (for double-sided printing)
|
// Foreword always takes 2 pages (for double-sided printing)
|
||||||
val forewordPages = if (foreword != null) 2 else 0
|
val forewordPages = if (foreword != null) 2 else 0
|
||||||
|
|
||||||
|
val headerPages = introPages + estimatedTocPages + forewordPages
|
||||||
val paginationEngine = PaginationEngine(config)
|
val paginationEngine = PaginationEngine(config)
|
||||||
val pages = paginationEngine.paginate(measuredSongs, tocPages + forewordPages)
|
val pages = paginationEngine.paginate(measuredSongs, headerPages)
|
||||||
|
|
||||||
val tocEntries = tocGenerator.generate(pages, tocPages + forewordPages)
|
// Generate initial TOC entries, then measure actual pages needed
|
||||||
|
val initialTocEntries = tocGenerator.generate(pages, headerPages)
|
||||||
|
val tocRenderer = TocRenderer(fontMetrics, config)
|
||||||
|
val tocPages = tocRenderer.measurePages(initialTocEntries)
|
||||||
|
|
||||||
|
// Re-generate TOC entries with corrected page offset if count changed.
|
||||||
|
// Since tocPages is always even, the pagination layout (left/right parity)
|
||||||
|
// stays the same — only page numbers in the TOC entries need updating.
|
||||||
|
val actualHeaderPages = introPages + tocPages + forewordPages
|
||||||
|
val tocEntries = if (tocPages != estimatedTocPages) {
|
||||||
|
logger.info { "TOC pages: estimated $estimatedTocPages, actual $tocPages" }
|
||||||
|
tocGenerator.generate(pages, actualHeaderPages)
|
||||||
|
} else {
|
||||||
|
initialTocEntries
|
||||||
|
}
|
||||||
|
|
||||||
// Build final page list with foreword pages inserted before song content
|
// Build final page list with foreword pages inserted before song content
|
||||||
val allPages = mutableListOf<PageContent>()
|
val allPages = mutableListOf<PageContent>()
|
||||||
@@ -141,14 +167,17 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
allPages.addAll(pages)
|
allPages.addAll(pages)
|
||||||
|
|
||||||
val layoutResult = LayoutResult(
|
val layoutResult = LayoutResult(
|
||||||
|
introPages = introPages,
|
||||||
tocPages = tocPages,
|
tocPages = tocPages,
|
||||||
pages = allPages,
|
pages = allPages,
|
||||||
tocEntries = tocEntries
|
tocEntries = tocEntries
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
val totalPages = introPages + tocPages + pages.size
|
||||||
|
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
|
||||||
|
|
||||||
// 5. Render PDF
|
// 5. Render PDF
|
||||||
|
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
|
||||||
val outputDir = File(projectDir, config.output.directory)
|
val outputDir = File(projectDir, config.output.directory)
|
||||||
outputDir.mkdirs()
|
outputDir.mkdirs()
|
||||||
val outputFile = File(outputDir, config.output.filename)
|
val outputFile = File(outputDir, config.output.filename)
|
||||||
@@ -160,13 +189,13 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
renderer.render(layoutResult, config, fos)
|
renderer.render(layoutResult, config, fos)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
logger.info { "Build complete: ${sortedSongs.size} songs, $totalPages pages" }
|
||||||
|
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success = true,
|
success = true,
|
||||||
outputFile = outputFile,
|
outputFile = outputFile,
|
||||||
songCount = sortedSongs.size,
|
songCount = sortedSongs.size,
|
||||||
pageCount = pages.size + tocPages
|
pageCount = totalPages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class BuildCommand : CliktCommand(name = "build") {
|
|||||||
echo("Building songbook from: ${dir.path}")
|
echo("Building songbook from: ${dir.path}")
|
||||||
|
|
||||||
val pipeline = SongbookPipeline(dir)
|
val pipeline = SongbookPipeline(dir)
|
||||||
val result = pipeline.build()
|
val result = pipeline.build(onProgress = { msg -> echo(msg) })
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
echo("Build successful!")
|
echo("Build successful!")
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fun App() {
|
|||||||
var isCustomOrder by remember { mutableStateOf(false) }
|
var isCustomOrder by remember { mutableStateOf(false) }
|
||||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||||
var isRunning by remember { mutableStateOf(false) }
|
var isRunning by remember { mutableStateOf(false) }
|
||||||
|
var isLoadingSongs by remember { mutableStateOf(false) }
|
||||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||||
val previewState = remember { PdfPreviewState() }
|
val previewState = remember { PdfPreviewState() }
|
||||||
|
|
||||||
@@ -65,15 +66,20 @@ fun App() {
|
|||||||
isCustomOrder = false
|
isCustomOrder = false
|
||||||
if (!projectDir.isDirectory) return
|
if (!projectDir.isDirectory) return
|
||||||
|
|
||||||
|
isLoadingSongs = true
|
||||||
|
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val (loadedSongs, order) = withContext(Dispatchers.IO) {
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
var songsDir: File
|
var songsDir: File
|
||||||
songsOrderConfig = "alphabetical"
|
var orderConfig = "alphabetical"
|
||||||
|
|
||||||
if (configFile.exists()) {
|
if (configFile.exists()) {
|
||||||
try {
|
try {
|
||||||
val config = ConfigParser.parse(configFile)
|
val config = ConfigParser.parse(configFile)
|
||||||
songsDir = File(projectDir, config.songs.directory)
|
songsDir = File(projectDir, config.songs.directory)
|
||||||
songsOrderConfig = config.songs.order
|
orderConfig = config.songs.order
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
songsDir = File(projectDir, "songs")
|
songsDir = File(projectDir, "songs")
|
||||||
}
|
}
|
||||||
@@ -81,13 +87,13 @@ fun App() {
|
|||||||
songsDir = File(projectDir, "songs")
|
songsDir = File(projectDir, "songs")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!songsDir.isDirectory) return
|
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig)
|
||||||
|
|
||||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||||
?.sortedBy { it.name }
|
?.sortedBy { it.name }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
|
|
||||||
val loadedSongs = songFiles.mapNotNull { file ->
|
val loaded = songFiles.mapNotNull { file ->
|
||||||
try {
|
try {
|
||||||
val song = ChordProParser.parseFile(file)
|
val song = ChordProParser.parseFile(file)
|
||||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
||||||
@@ -95,14 +101,23 @@ fun App() {
|
|||||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Pair(loaded, orderConfig)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply config-based sort for display
|
songsOrderConfig = order
|
||||||
songs = if (songsOrderConfig == "alphabetical") {
|
songs = if (order == "alphabetical") {
|
||||||
loadedSongs.sortedBy { it.title.lowercase() }
|
loadedSongs.sortedBy { it.title.lowercase() }
|
||||||
} else {
|
} else {
|
||||||
loadedSongs
|
loadedSongs
|
||||||
}
|
}
|
||||||
originalSongs = songs.toList()
|
originalSongs = songs.toList()
|
||||||
|
isLoadingSongs = false
|
||||||
|
statusMessages = if (loadedSongs.isNotEmpty()) {
|
||||||
|
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
|
||||||
|
} else {
|
||||||
|
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
@@ -185,7 +200,18 @@ fun App() {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
if (isLoadingSongs) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Lieder werden geladen...", color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
|
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
|
||||||
@@ -233,7 +259,9 @@ fun App() {
|
|||||||
}
|
}
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
SongbookPipeline(File(projectPath)).build(customOrder)
|
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
|
||||||
|
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
BuildResult(
|
BuildResult(
|
||||||
success = false,
|
success = false,
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ data class BookConfig(
|
|||||||
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()
|
val toc: TocConfig = TocConfig(),
|
||||||
|
val intro: IntroConfig? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class IntroConfig(
|
||||||
|
val enabled: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TocConfig(
|
data class TocConfig(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ sealed class PageContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class LayoutResult(
|
data class LayoutResult(
|
||||||
|
val introPages: Int = 0,
|
||||||
val tocPages: Int,
|
val tocPages: Int,
|
||||||
val pages: List<PageContent>,
|
val pages: List<PageContent>,
|
||||||
val tocEntries: List<TocEntry>
|
val tocEntries: List<TocEntry>
|
||||||
|
|||||||
@@ -26,11 +26,24 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
val writer = PdfWriter.getInstance(document, output)
|
val writer = PdfWriter.getInstance(document, output)
|
||||||
document.open()
|
document.open()
|
||||||
|
|
||||||
// Render TOC first
|
// Render intro page (title page) if configured
|
||||||
|
if (layout.introPages > 0) {
|
||||||
|
val cb = writer.directContent
|
||||||
|
renderIntroPage(cb, fontMetrics, config, pageSize)
|
||||||
|
// Blank back of intro page (for double-sided printing)
|
||||||
|
document.newPage()
|
||||||
|
writer.directContent.let { c -> c.beginText(); c.endText() }
|
||||||
|
document.newPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render TOC
|
||||||
if (layout.tocEntries.isNotEmpty()) {
|
if (layout.tocEntries.isNotEmpty()) {
|
||||||
tocRenderer.render(document, writer, layout.tocEntries)
|
tocRenderer.render(document, writer, layout.tocEntries)
|
||||||
// Add blank pages to fill TOC allocation
|
// Pad with blank pages to fill the allocated TOC page count.
|
||||||
repeat(layout.tocPages - 1) {
|
// The table auto-paginates, so we only add the difference.
|
||||||
|
val tocPagesUsed = writer.pageNumber - layout.introPages
|
||||||
|
val paddingNeeded = maxOf(0, layout.tocPages - tocPagesUsed)
|
||||||
|
repeat(paddingNeeded) {
|
||||||
document.newPage()
|
document.newPage()
|
||||||
// Force new page even if empty
|
// Force new page even if empty
|
||||||
writer.directContent.let { cb ->
|
writer.directContent.let { cb ->
|
||||||
@@ -42,7 +55,7 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render content pages
|
// Render content pages
|
||||||
var currentPageNum = layout.tocPages + 1
|
var currentPageNum = layout.introPages + layout.tocPages + 1
|
||||||
for (pageContent in layout.pages) {
|
for (pageContent in layout.pages) {
|
||||||
// Swap margins for left/right pages
|
// Swap margins for left/right pages
|
||||||
val isRightPage = currentPageNum % 2 == 1
|
val isRightPage = currentPageNum % 2 == 1
|
||||||
@@ -729,6 +742,64 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun renderIntroPage(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
fontMetrics: PdfFontMetrics,
|
||||||
|
config: BookConfig,
|
||||||
|
pageSize: Rectangle
|
||||||
|
) {
|
||||||
|
val pageWidth = pageSize.width
|
||||||
|
val pageHeight = pageSize.height
|
||||||
|
|
||||||
|
// Title centered on the page
|
||||||
|
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||||
|
val titleSize = config.fonts.title.size * 2.5f
|
||||||
|
val title = config.book.title
|
||||||
|
val titleWidth = titleFont.getWidthPoint(title, titleSize)
|
||||||
|
val titleX = (pageWidth - titleWidth) / 2
|
||||||
|
val titleY = pageHeight * 0.55f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(titleFont, titleSize)
|
||||||
|
cb.setColorFill(Color.BLACK)
|
||||||
|
cb.setTextMatrix(titleX, titleY)
|
||||||
|
cb.showText(title)
|
||||||
|
cb.endText()
|
||||||
|
|
||||||
|
// Subtitle below the title
|
||||||
|
if (config.book.subtitle != null) {
|
||||||
|
val subtitleSize = config.fonts.title.size * 1.2f
|
||||||
|
val subtitle = config.book.subtitle!!
|
||||||
|
val subtitleWidth = titleFont.getWidthPoint(subtitle, subtitleSize)
|
||||||
|
val subtitleX = (pageWidth - subtitleWidth) / 2
|
||||||
|
val subtitleY = titleY - titleSize * 1.8f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(titleFont, subtitleSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(subtitleX, subtitleY)
|
||||||
|
cb.showText(subtitle)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edition at the bottom of the page
|
||||||
|
if (config.book.edition != null) {
|
||||||
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
|
val metaSize = config.fonts.metadata.size * 1.2f
|
||||||
|
val edition = config.book.edition!!
|
||||||
|
val editionWidth = metaFont.getWidthPoint(edition, metaSize)
|
||||||
|
val editionX = (pageWidth - editionWidth) / 2
|
||||||
|
val editionY = pageHeight * 0.1f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.GRAY)
|
||||||
|
cb.setTextMatrix(editionX, editionY)
|
||||||
|
cb.showText(edition)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -12,6 +12,32 @@ class TocRenderer(
|
|||||||
// Light gray background for the highlighted column
|
// Light gray background for the highlighted column
|
||||||
private val highlightColor = Color(220, 220, 220)
|
private val highlightColor = Color(220, 220, 220)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-renders the TOC to a temporary document and returns the number of pages needed,
|
||||||
|
* rounded up to an even number for double-sided printing.
|
||||||
|
*/
|
||||||
|
fun measurePages(tocEntries: List<TocEntry>): Int {
|
||||||
|
val pageSize = if (config.book.format == "A5") PageSize.A5 else PageSize.A4
|
||||||
|
val marginInner = config.layout.margins.inner / 0.3528f
|
||||||
|
val marginOuter = config.layout.margins.outer / 0.3528f
|
||||||
|
val marginTop = config.layout.margins.top / 0.3528f
|
||||||
|
val marginBottom = config.layout.margins.bottom / 0.3528f
|
||||||
|
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
val doc = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
|
||||||
|
val writer = PdfWriter.getInstance(doc, baos)
|
||||||
|
doc.open()
|
||||||
|
render(doc, writer, tocEntries)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
val reader = PdfReader(baos.toByteArray())
|
||||||
|
val pageCount = reader.numberOfPages
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
// Round to even for double-sided printing
|
||||||
|
return if (pageCount % 2 == 0) pageCount else pageCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -30,19 +56,18 @@ class TocRenderer(
|
|||||||
val table = PdfPTable(numCols)
|
val table = PdfPTable(numCols)
|
||||||
table.widthPercentage = 100f
|
table.widthPercentage = 100f
|
||||||
|
|
||||||
// Set column widths: title takes most space
|
// Set column widths: title takes most space, ref columns need room for 3-digit numbers
|
||||||
val widths = FloatArray(numCols)
|
val widths = FloatArray(numCols)
|
||||||
widths[0] = 10f // title
|
widths[0] = 12f // title
|
||||||
widths[1] = 1.5f // page
|
widths[1] = 1.5f // page
|
||||||
for (i in refBooks.indices) {
|
for (i in refBooks.indices) {
|
||||||
widths[2 + i] = 1.5f
|
widths[2 + i] = 1.5f // enough for 3-digit page numbers; headers are rotated 90°
|
||||||
}
|
}
|
||||||
table.setWidths(widths)
|
table.setWidths(widths)
|
||||||
|
|
||||||
// Determine which column index should be highlighted
|
// Determine which column index should be highlighted
|
||||||
val highlightAbbrev = config.toc.highlightColumn
|
val highlightAbbrev = config.toc.highlightColumn
|
||||||
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
|
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
|
||||||
// Check "Seite" (page) column first - the current book's page number column
|
|
||||||
if (highlightAbbrev == "Seite") {
|
if (highlightAbbrev == "Seite") {
|
||||||
1
|
1
|
||||||
} else {
|
} else {
|
||||||
@@ -51,13 +76,13 @@ class TocRenderer(
|
|||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
// Header row
|
// Header row — reference book columns are rotated 90°
|
||||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||||
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
||||||
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
||||||
for ((i, book) in refBooks.withIndex()) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
val isHighlighted = highlightColumnIndex == 2 + i
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
|
table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted))
|
||||||
}
|
}
|
||||||
table.headerRows = 1
|
table.headerRows = 1
|
||||||
|
|
||||||
@@ -71,7 +96,7 @@ class TocRenderer(
|
|||||||
for ((i, book) in refBooks.withIndex()) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
val ref = entry.references[book.abbreviation]
|
val ref = entry.references[book.abbreviation]
|
||||||
val isHighlighted = highlightColumnIndex == 2 + i
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
|
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_CENTER, isHighlighted = isHighlighted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +108,27 @@ class TocRenderer(
|
|||||||
cell.borderWidth = 0f
|
cell.borderWidth = 0f
|
||||||
cell.borderWidthBottom = 0.5f
|
cell.borderWidthBottom = 0.5f
|
||||||
cell.paddingBottom = 4f
|
cell.paddingBottom = 4f
|
||||||
|
cell.verticalAlignment = Element.ALIGN_BOTTOM
|
||||||
|
if (isHighlighted) {
|
||||||
|
cell.backgroundColor = highlightColor
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a header cell with text rotated 90° counterclockwise.
|
||||||
|
* Used for reference book column headers to save horizontal space.
|
||||||
|
*/
|
||||||
|
private fun rotatedHeaderCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
|
||||||
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
|
cell.borderWidth = 0f
|
||||||
|
cell.borderWidthBottom = 0.5f
|
||||||
|
cell.rotation = 90
|
||||||
|
cell.horizontalAlignment = Element.ALIGN_CENTER
|
||||||
|
cell.verticalAlignment = Element.ALIGN_MIDDLE
|
||||||
|
// Ensure cell is tall enough for the rotated text
|
||||||
|
val textWidth = font.baseFont.getWidthPoint(text, font.size)
|
||||||
|
cell.minimumHeight = textWidth + 8f
|
||||||
if (isHighlighted) {
|
if (isHighlighted) {
|
||||||
cell.backgroundColor = highlightColor
|
cell.backgroundColor = highlightColor
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user