diff --git a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt index 3a3ed95..d1a2c60 100644 --- a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt +++ b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt @@ -4,7 +4,9 @@ import de.pfadfinder.songbook.model.* import de.pfadfinder.songbook.parser.* import de.pfadfinder.songbook.parser.ForewordParser 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 java.io.File 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 * config-based sort (alphabetical or manual). Files not in this list are * appended at the end. + * @param onProgress Optional callback invoked with status messages during the build. */ - fun build(customSongOrder: List? = null): BuildResult { + fun build(customSongOrder: List? = null, onProgress: ((String) -> Unit)? = null): BuildResult { // 1. Parse config + onProgress?.invoke("Konfiguration wird geladen...") val configFile = File(projectDir, "songbook.yaml") if (!configFile.exists()) { 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"))) } + onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...") logger.info { "Found ${songFiles.size} song files" } val songsByFileName = mutableMapOf() val allErrors = mutableListOf() - 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 { val song = ChordProParser.parseFile(file) val songErrors = Validator.validateSong(song, file.name) @@ -116,21 +124,39 @@ class SongbookPipeline(private val projectDir: File) { } // 3. Measure songs + onProgress?.invoke("Layout wird berechnet...") val fontMetrics = PdfFontMetrics() val measurementEngine = MeasurementEngine(fontMetrics, config) val measuredSongs = sortedSongs.map { measurementEngine.measure(it) } // 4. Generate TOC and paginate 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) val forewordPages = if (foreword != null) 2 else 0 + val headerPages = introPages + estimatedTocPages + forewordPages 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 val allPages = mutableListOf() @@ -141,14 +167,17 @@ class SongbookPipeline(private val projectDir: File) { allPages.addAll(pages) val layoutResult = LayoutResult( + introPages = introPages, tocPages = tocPages, pages = allPages, 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 + onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...") val outputDir = File(projectDir, config.output.directory) outputDir.mkdirs() val outputFile = File(outputDir, config.output.filename) @@ -160,13 +189,13 @@ class SongbookPipeline(private val projectDir: File) { 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( success = true, outputFile = outputFile, songCount = sortedSongs.size, - pageCount = pages.size + tocPages + pageCount = totalPages ) } diff --git a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt index 0d9c87a..367e98c 100644 --- a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt +++ b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt @@ -18,7 +18,7 @@ class BuildCommand : CliktCommand(name = "build") { echo("Building songbook from: ${dir.path}") val pipeline = SongbookPipeline(dir) - val result = pipeline.build() + val result = pipeline.build(onProgress = { msg -> echo(msg) }) if (result.success) { echo("Build successful!") diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt index d02b58a..784f077 100644 --- a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt @@ -51,6 +51,7 @@ fun App() { var isCustomOrder by remember { mutableStateOf(false) } var statusMessages by remember { mutableStateOf>(emptyList()) } var isRunning by remember { mutableStateOf(false) } + var isLoadingSongs by remember { mutableStateOf(false) } var lastBuildResult by remember { mutableStateOf(null) } val previewState = remember { PdfPreviewState() } @@ -65,44 +66,58 @@ fun App() { isCustomOrder = false if (!projectDir.isDirectory) return - val configFile = File(projectDir, "songbook.yaml") - var songsDir: File - songsOrderConfig = "alphabetical" + isLoadingSongs = true + statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO)) - if (configFile.exists()) { - try { - val config = ConfigParser.parse(configFile) - songsDir = File(projectDir, config.songs.directory) - songsOrderConfig = config.songs.order - } catch (_: Exception) { - songsDir = File(projectDir, "songs") + scope.launch { + val (loadedSongs, order) = withContext(Dispatchers.IO) { + val configFile = File(projectDir, "songbook.yaml") + var songsDir: File + var orderConfig = "alphabetical" + + if (configFile.exists()) { + try { + val config = ConfigParser.parse(configFile) + songsDir = File(projectDir, config.songs.directory) + orderConfig = config.songs.order + } catch (_: Exception) { + songsDir = File(projectDir, "songs") + } + } else { + songsDir = File(projectDir, "songs") + } + + if (!songsDir.isDirectory) return@withContext Pair(emptyList(), orderConfig) + + val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") } + ?.sortedBy { it.name } + ?: emptyList() + + val loaded = songFiles.mapNotNull { file -> + try { + val song = ChordProParser.parseFile(file) + SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension }) + } catch (_: Exception) { + SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)") + } + } + Pair(loaded, orderConfig) } - } else { - songsDir = File(projectDir, "songs") - } - if (!songsDir.isDirectory) return - - val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") } - ?.sortedBy { it.name } - ?: emptyList() - - val loadedSongs = songFiles.mapNotNull { file -> - try { - val song = ChordProParser.parseFile(file) - SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension }) - } catch (_: Exception) { - SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)") + songsOrderConfig = order + songs = if (order == "alphabetical") { + loadedSongs.sortedBy { it.title.lowercase() } + } else { + loadedSongs + } + 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)) } } - - // Apply config-based sort for display - songs = if (songsOrderConfig == "alphabetical") { - loadedSongs.sortedBy { it.title.lowercase() } - } else { - loadedSongs - } - originalSongs = songs.toList() } MaterialTheme { @@ -185,7 +200,18 @@ fun App() { } 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()) { Text( "Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.", @@ -233,7 +259,9 @@ fun App() { } val result = withContext(Dispatchers.IO) { try { - SongbookPipeline(File(projectPath)).build(customOrder) + SongbookPipeline(File(projectPath)).build(customOrder) { msg -> + statusMessages = listOf(StatusMessage(msg, MessageType.INFO)) + } } catch (e: Exception) { BuildResult( success = false, diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt index 7a323bc..c00232d 100644 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt @@ -9,7 +9,12 @@ data class BookConfig( val referenceBooks: List = emptyList(), val output: OutputConfig = OutputConfig(), 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( diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt index b3a00bd..5311c1e 100644 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt @@ -14,6 +14,7 @@ sealed class PageContent { } data class LayoutResult( + val introPages: Int = 0, val tocPages: Int, val pages: List, val tocEntries: List diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt index d6fb6e3..2e12711 100644 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt @@ -26,11 +26,24 @@ class PdfBookRenderer : BookRenderer { val writer = PdfWriter.getInstance(document, output) 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()) { tocRenderer.render(document, writer, layout.tocEntries) - // Add blank pages to fill TOC allocation - repeat(layout.tocPages - 1) { + // Pad with blank pages to fill the allocated TOC page count. + // 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() // Force new page even if empty writer.directContent.let { cb -> @@ -42,7 +55,7 @@ class PdfBookRenderer : BookRenderer { } // Render content pages - var currentPageNum = layout.tocPages + 1 + var currentPageNum = layout.introPages + layout.tocPages + 1 for (pageContent in layout.pages) { // Swap margins for left/right pages 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) { try { val img = Image.getInstance(imagePath) diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt index b7fb3d0..3dc5df8 100644 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt @@ -12,6 +12,32 @@ class TocRenderer( // Light gray background for the highlighted column 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): 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) { val tocFont = fontMetrics.getBaseFont(config.fonts.toc) val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc) @@ -30,19 +56,18 @@ class TocRenderer( val table = PdfPTable(numCols) 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) - widths[0] = 10f // title - widths[1] = 1.5f // page + widths[0] = 12f // title + widths[1] = 1.5f // page 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) // 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 { @@ -51,13 +76,13 @@ class TocRenderer( } } else null - // Header row + // Header row — reference book columns are rotated 90° val headerFont = Font(tocBoldFont, fontSize, Font.BOLD) table.addCell(headerCell("Titel", headerFont, isHighlighted = false)) table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1)) for ((i, book) in refBooks.withIndex()) { val isHighlighted = highlightColumnIndex == 2 + i - table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted)) + table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted)) } table.headerRows = 1 @@ -71,7 +96,7 @@ class TocRenderer( for ((i, book) in refBooks.withIndex()) { val ref = entry.references[book.abbreviation] 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.borderWidthBottom = 0.5f 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) { cell.backgroundColor = highlightColor }