Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d346e899d | ||
| 9056dbd9cd | |||
| 543fe66a44 | |||
| 032387c02d | |||
| a251fac053 | |||
| 077b3c027e | |||
| d733e83cb1 | |||
| 0f038a68d8 | |||
| a69d14033d | |||
| 0fb2771279 |
109
.plans/issue-17-page-overflow.md
Normal file
109
.plans/issue-17-page-overflow.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Issue #17: Fix page overflow — bounds checking and content splitting
|
||||
|
||||
## Summary
|
||||
|
||||
The PDF renderer (`PdfBookRenderer.renderSongPage()`) currently renders all song sections on page 0 and leaves page 1 blank for 2-page songs. There is no bounds checking — the `y` coordinate can go below the bottom margin, causing content to render outside the visible page area. This plan adds proper `y`-position tracking against a minimum `yMin` boundary, splits content across pages at section boundaries when a song exceeds one page, and reserves space for bottom metadata/references on the last page.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. The renderer tracks `y` against the bottom margin during song page rendering
|
||||
2. For 2-page songs, content splits across pages when it exceeds page 0's available space — remaining sections continue on page 1
|
||||
3. Content that would be rendered below the bottom margin (minus reserved footer space) is moved to the next page
|
||||
4. If metadata is "bottom" position, sufficient space is reserved at the bottom of the last page
|
||||
5. No text or images are rendered outside the printable page area
|
||||
6. Existing tests continue to pass
|
||||
7. New tests verify content splitting for songs exceeding one page
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add a section height calculation helper in PdfBookRenderer
|
||||
|
||||
Add a private method `calculateSectionHeight()` that computes how many PDF points a given `SongSection` will consume when rendered. This mirrors the measurement engine logic but uses the actual PDF `BaseFont` widths (not stubs). This is needed to decide whether a section fits on the current page.
|
||||
|
||||
The method signature:
|
||||
```kotlin
|
||||
private fun calculateSectionHeight(
|
||||
section: SongSection,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float
|
||||
```
|
||||
|
||||
### Step 2: Add footer space reservation calculation
|
||||
|
||||
Add a private method `calculateFooterReservation()` that computes how much vertical space must be reserved at the bottom of the **last** page of a song for:
|
||||
- Bottom-position metadata (if `metadataPosition == "bottom"`)
|
||||
- Notes
|
||||
- Reference book footer
|
||||
|
||||
```kotlin
|
||||
private fun calculateFooterReservation(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float
|
||||
```
|
||||
|
||||
### Step 3: Refactor renderSongPage() to split content across pages
|
||||
|
||||
The key change: Instead of `val sections = if (pageIndex == 0) song.sections else emptyList()`, determine which sections belong on each page by:
|
||||
|
||||
1. Calculate `yMin` = bottom margin in points (plus footer reservation for the last page)
|
||||
2. For `pageIndex == 0`: Render sections in order. Before rendering each section, check if the section's height fits above `yMin`. If not, stop — remaining sections go to page 1.
|
||||
3. For `pageIndex == 1`: Render the sections that didn't fit on page 0. The split point is stored via a `splitIndex` that is computed during page 0 rendering.
|
||||
|
||||
**Approach:** Since `renderSongPage()` is called separately for page 0 and page 1, we need a way to know the split point on both calls. The cleanest approach is to compute the split index as a function:
|
||||
|
||||
```kotlin
|
||||
private fun computeSplitIndex(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float,
|
||||
availableHeight: Float // total space on page 0 (contentTop - yMin)
|
||||
): Int // index of first section that goes to page 1
|
||||
```
|
||||
|
||||
This method calculates the cumulative height of header + sections. When the cumulative height exceeds `availableHeight`, it returns the section index. If all sections fit, it returns `song.sections.size`.
|
||||
|
||||
### Step 4: Update renderSongPage() to use bounds checking during rendering
|
||||
|
||||
Even after determining the split, the actual rendering loop should still check `y >= yMin` as a safety net. If a section that was estimated to fit actually overflows (due to measurement inaccuracy), clamp rendering — do not render below `yMin`.
|
||||
|
||||
### Step 5: Update footer rendering for multi-page songs
|
||||
|
||||
Currently `isLastPage` is hardcoded to `pageIndex == 0`. Change it to correctly identify the last page:
|
||||
- For 1-page songs: `pageIndex == 0` is the last page
|
||||
- For 2-page songs: `pageIndex == 1` is the last page
|
||||
|
||||
The song's `pageCount` isn't directly available in the renderer, but we can determine it: if `pageIndex == 1`, it's always the last page. If `pageIndex == 0`, it's the last page only if the song fits on one page (i.e., `computeSplitIndex == song.sections.size`).
|
||||
|
||||
A simpler approach: pass the total page count as a parameter, or compute whether the song needs 2 pages inside `renderSongPage()`.
|
||||
|
||||
**Decision:** Add a `totalPages: Int` parameter to `renderSongPage()`. The caller already knows this from the `PageContent.SongPage` list (consecutive song pages with pageIndex 0 and 1 for the same song).
|
||||
|
||||
Actually, the simplest approach: The renderer sees `PageContent.SongPage(song, 0)` and `PageContent.SongPage(song, 1)` in the page list. We can pre-scan the pages list to know if a song has 2 pages. But even simpler: we can compute `computeSplitIndex` to know whether the song needs a second page. If `splitIndex < song.sections.size`, the song has 2 pages.
|
||||
|
||||
### Step 6: Move notes and bottom-metadata to the last page
|
||||
|
||||
Currently notes and bottom metadata only render on `pageIndex == 0`. Change this to render on the last page (which might be page 1 for 2-page songs). The logic:
|
||||
- Compute `isLastPage` based on split index
|
||||
- Render notes, bottom metadata, and reference footer only on the last page
|
||||
|
||||
### Step 7: Write tests
|
||||
|
||||
Add tests in `PdfBookRendererTest`:
|
||||
|
||||
1. `render handles two-page song with content split across pages` — Create a song with many sections that exceed one page, render with pageIndex 0 and 1, verify PDF is valid.
|
||||
|
||||
2. `render does not overflow below bottom margin` — Create a very long song, verify rendering completes without error.
|
||||
|
||||
3. `render places metadata at bottom of last page for two-page songs` — Use `metadataPosition = "bottom"`, create a 2-page song, verify PDF is valid.
|
||||
|
||||
4. `render handles notes on last page of two-page song` — Song with notes that spans 2 pages, verify rendering.
|
||||
|
||||
### Step 8: Verify existing tests pass
|
||||
|
||||
Run `gradle :renderer-pdf:test` and `gradle :app:test` to ensure no regressions.
|
||||
44
.plans/issue-18-page-preview.md
Normal file
44
.plans/issue-18-page-preview.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Issue #18: Add page-by-page preview in the GUI after building
|
||||
|
||||
## Summary
|
||||
|
||||
Add a PDF preview panel to the GUI that appears after a successful build. It renders PDF pages as images using Apache PDFBox and displays them with previous/next navigation and a page counter. The preview loads pages lazily for performance and updates automatically on new builds.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. After a successful build, a preview panel appears showing generated pages
|
||||
2. Users can navigate between pages (previous/next buttons)
|
||||
3. Current page number and total count displayed (e.g., "Seite 3 / 42")
|
||||
4. Preview renders actual PDF pages as images (PDF-to-image via PDFBox)
|
||||
5. Preview panel can be closed/hidden to return to normal view
|
||||
6. Preview updates automatically when a new build completes
|
||||
7. GUI remains responsive while preview is loading (async rendering)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add PDFBox dependency to gui module
|
||||
|
||||
Add `org.apache.pdfbox:pdfbox:3.0.4` to gui/build.gradle.kts.
|
||||
|
||||
### Step 2: Create PdfPreviewState class
|
||||
|
||||
A state holder for the preview: current page index, total pages, rendered page images (cached), loading state. Pages are rendered lazily — only the current page is rendered at a time.
|
||||
|
||||
### Step 3: Create PdfPreviewPanel composable
|
||||
|
||||
A Compose panel with:
|
||||
- An Image composable showing the current page
|
||||
- Navigation row: "< Prev" button | "Seite X / Y" label | "Next >" button
|
||||
- A close/hide button
|
||||
- Loading indicator while page is rendering
|
||||
|
||||
### Step 4: Integrate preview into App composable
|
||||
|
||||
After a successful build:
|
||||
- Show a "Vorschau" button in the action buttons row
|
||||
- When clicked, show the preview panel (replacing or overlaying the song list area)
|
||||
- When a new build succeeds, update the preview automatically
|
||||
|
||||
### Step 5: Lazy page rendering
|
||||
|
||||
Render pages on demand using coroutines on Dispatchers.IO to keep the UI responsive.
|
||||
41
.plans/issue-19-drag-and-drop.md
Normal file
41
.plans/issue-19-drag-and-drop.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Issue #19: Add drag-and-drop song reordering in the GUI
|
||||
|
||||
## Summary
|
||||
|
||||
Add drag-and-drop reordering of songs in the GUI song list. When `songs.order` is "manual", users can drag songs to rearrange them. The custom order is passed to the pipeline at build time. When order is "alphabetical", drag-and-drop is disabled with a hint.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. Songs can be reordered via drag-and-drop
|
||||
2. Reordered list is used when building (overrides config order)
|
||||
3. Visual indicator shows drop target (highlight)
|
||||
4. Order can be reset via a button
|
||||
5. Reordering only enabled when songs.order is "manual"
|
||||
6. When alphabetical, list shows alphabetical order and drag is disabled (with hint)
|
||||
7. GUI remains responsive during drag operations
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add customSongOrder parameter to SongbookPipeline.build()
|
||||
|
||||
Add an optional `customSongOrder: List<String>? = null` parameter. When provided, use this ordered list of file names to sort the parsed songs instead of the config-based sort.
|
||||
|
||||
### Step 2: Create ReorderableSongList composable
|
||||
|
||||
Build a song list that supports drag-and-drop reordering:
|
||||
- Use `detectDragGesturesAfterLongPress` on each item to detect drag start
|
||||
- Track the dragged item index and current hover position
|
||||
- Show a visual indicator (highlighted background) at the drop target
|
||||
- On drop, reorder the list
|
||||
|
||||
### Step 3: Integrate into App.kt
|
||||
|
||||
- Track `songsOrder` config value ("alphabetical" or "manual")
|
||||
- Track `originalSongs` list (from file loading) to support reset
|
||||
- When manual: enable drag-and-drop, show reset button
|
||||
- When alphabetical: disable drag-and-drop, show hint
|
||||
- Pass custom order (file names) to pipeline on build
|
||||
|
||||
### Step 4: Add reset button
|
||||
|
||||
"Reihenfolge zurücksetzen" button restores `songs` to `originalSongs`.
|
||||
@@ -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
|
||||
@@ -21,8 +23,18 @@ data class BuildResult(
|
||||
|
||||
class SongbookPipeline(private val projectDir: File) {
|
||||
|
||||
fun build(): BuildResult {
|
||||
/**
|
||||
* Build the songbook PDF.
|
||||
*
|
||||
* @param customSongOrder Optional list of song file names in the desired order.
|
||||
* 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<String>? = 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")))
|
||||
@@ -53,19 +65,23 @@ 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 songs = mutableListOf<Song>()
|
||||
val songsByFileName = mutableMapOf<String, Song>()
|
||||
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 {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songs.add(song)
|
||||
songsByFileName[file.name] = song
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
@@ -76,10 +92,20 @@ class SongbookPipeline(private val projectDir: File) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
// Sort songs
|
||||
val sortedSongs = when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
val songs = songsByFileName.values.toList()
|
||||
|
||||
// Sort songs: custom order takes priority, then config-based sort
|
||||
val sortedSongs = if (customSongOrder != null) {
|
||||
val orderMap = customSongOrder.withIndex().associate { (index, name) -> name to index }
|
||||
songs.sortedBy { song ->
|
||||
val fileName = songsByFileName.entries.find { it.value === song }?.key
|
||||
orderMap[fileName] ?: Int.MAX_VALUE
|
||||
}
|
||||
} else {
|
||||
when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||
@@ -98,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<PageContent>()
|
||||
@@ -123,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)
|
||||
@@ -142,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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||
implementation("org.apache.pdfbox:pdfbox:3.0.4")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.ui.window.application
|
||||
import de.pfadfinder.songbook.app.BuildResult
|
||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||
import de.pfadfinder.songbook.parser.ChordProParser
|
||||
import de.pfadfinder.songbook.parser.ConfigParser
|
||||
import de.pfadfinder.songbook.parser.ValidationError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -44,47 +46,83 @@ data class SongEntry(val fileName: String, val title: String)
|
||||
fun App() {
|
||||
var projectPath by remember { mutableStateOf("") }
|
||||
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
||||
var originalSongs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
||||
var songsOrderConfig by remember { mutableStateOf("alphabetical") }
|
||||
var isCustomOrder by remember { mutableStateOf(false) }
|
||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var isLoadingSongs by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
val previewState = remember { PdfPreviewState() }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val reorderEnabled = songsOrderConfig != "alphabetical"
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
originalSongs = emptyList()
|
||||
isCustomOrder = false
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
val songsDir = if (configFile.exists()) {
|
||||
try {
|
||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
||||
File(projectDir, config.songs.directory)
|
||||
} catch (_: Exception) {
|
||||
File(projectDir, "songs")
|
||||
isLoadingSongs = true
|
||||
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
|
||||
|
||||
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<SongEntry>(), 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 {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
songs = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Project directory selection
|
||||
Text(
|
||||
@@ -130,48 +168,76 @@ fun App() {
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Song list
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
// Central content area: song list or preview panel
|
||||
if (previewState.isVisible) {
|
||||
// Show preview panel
|
||||
PdfPreviewPanel(
|
||||
state = previewState,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
// Song list header with optional reset button
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (reorderEnabled && isCustomOrder) {
|
||||
Button(
|
||||
onClick = {
|
||||
songs = originalSongs.toList()
|
||||
isCustomOrder = false
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("Reihenfolge zuruecksetzen")
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswählen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(songs) { song ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
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.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswaehlen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ReorderableSongList(
|
||||
songs = songs,
|
||||
reorderEnabled = reorderEnabled,
|
||||
onReorder = { newList ->
|
||||
songs = newList
|
||||
isCustomOrder = true
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -185,9 +251,17 @@ fun App() {
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
||||
scope.launch {
|
||||
// Build custom song order from the current GUI list
|
||||
val customOrder = if (isCustomOrder) {
|
||||
songs.map { it.fileName }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build()
|
||||
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
|
||||
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
BuildResult(
|
||||
success = false,
|
||||
@@ -220,6 +294,11 @@ fun App() {
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
|
||||
// Automatically load preview after successful build
|
||||
if (result.success && result.outputFile != null) {
|
||||
previewState.loadPdf(result.outputFile!!)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
@@ -232,7 +311,7 @@ fun App() {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
|
||||
statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO))
|
||||
scope.launch {
|
||||
val errors = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -271,7 +350,7 @@ fun App() {
|
||||
Desktop.getDesktop().open(file)
|
||||
} catch (e: Exception) {
|
||||
statusMessages = statusMessages + StatusMessage(
|
||||
"PDF konnte nicht geöffnet werden: ${e.message}",
|
||||
"PDF konnte nicht geoeffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
@@ -279,7 +358,27 @@ fun App() {
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF öffnen")
|
||||
Text("PDF oeffnen")
|
||||
}
|
||||
|
||||
// Show/hide preview button
|
||||
Button(
|
||||
onClick = {
|
||||
if (previewState.isVisible) {
|
||||
previewState.isVisible = false
|
||||
} else {
|
||||
scope.launch {
|
||||
if (previewState.totalPages == 0) {
|
||||
lastBuildResult?.outputFile?.let { previewState.loadPdf(it) }
|
||||
} else {
|
||||
previewState.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text(if (previewState.isVisible) "Vorschau ausblenden" else "Vorschau")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +435,7 @@ fun App() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.pdfbox.Loader
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.rendering.PDFRenderer
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* State holder for the PDF preview. Manages the current page, total page count,
|
||||
* and a cache of rendered page images.
|
||||
*/
|
||||
class PdfPreviewState {
|
||||
var pdfFile: File? by mutableStateOf(null)
|
||||
private set
|
||||
var totalPages: Int by mutableStateOf(0)
|
||||
private set
|
||||
var currentPage: Int by mutableStateOf(0)
|
||||
private set
|
||||
var isLoading: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
var currentImage: ImageBitmap? by mutableStateOf(null)
|
||||
private set
|
||||
var isVisible: Boolean by mutableStateOf(false)
|
||||
|
||||
private var document: PDDocument? = null
|
||||
private var renderer: PDFRenderer? = null
|
||||
private val pageCache = mutableMapOf<Int, ImageBitmap>()
|
||||
|
||||
/**
|
||||
* Load a new PDF file for preview. Resets state and renders the first page.
|
||||
*/
|
||||
suspend fun loadPdf(file: File) {
|
||||
close()
|
||||
pdfFile = file
|
||||
currentPage = 0
|
||||
pageCache.clear()
|
||||
currentImage = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val doc = Loader.loadPDF(file)
|
||||
document = doc
|
||||
renderer = PDFRenderer(doc)
|
||||
totalPages = doc.numberOfPages
|
||||
} catch (_: Exception) {
|
||||
totalPages = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPages > 0) {
|
||||
isVisible = true
|
||||
renderCurrentPage()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun goToPage(page: Int) {
|
||||
if (page < 0 || page >= totalPages) return
|
||||
currentPage = page
|
||||
renderCurrentPage()
|
||||
}
|
||||
|
||||
suspend fun nextPage() {
|
||||
goToPage(currentPage + 1)
|
||||
}
|
||||
|
||||
suspend fun previousPage() {
|
||||
goToPage(currentPage - 1)
|
||||
}
|
||||
|
||||
private suspend fun renderCurrentPage() {
|
||||
val cached = pageCache[currentPage]
|
||||
if (cached != null) {
|
||||
currentImage = cached
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
try {
|
||||
val image = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val pdfRenderer = renderer ?: return@withContext null
|
||||
// Render at 150 DPI for a good balance of quality and speed
|
||||
val bufferedImage: BufferedImage = pdfRenderer.renderImageWithDPI(currentPage, 150f)
|
||||
bufferedImage.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image != null) {
|
||||
pageCache[currentPage] = image
|
||||
currentImage = image
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
document?.close()
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
document = null
|
||||
renderer = null
|
||||
pageCache.clear()
|
||||
currentImage = null
|
||||
totalPages = 0
|
||||
currentPage = 0
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfPreviewPanel(
|
||||
state: PdfPreviewState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Header with title and close button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Vorschau",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Button(
|
||||
onClick = { state.isVisible = false },
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("Schliessen")
|
||||
}
|
||||
}
|
||||
|
||||
// Page image area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFFE0E0E0)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (state.currentImage != null) {
|
||||
Image(
|
||||
bitmap = state.currentImage!!,
|
||||
contentDescription = "Seite ${state.currentPage + 1}",
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Keine Vorschau verfuegbar",
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { state.previousPage() }
|
||||
},
|
||||
enabled = state.currentPage > 0 && !state.isLoading
|
||||
) {
|
||||
Text("<")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Seite ${state.currentPage + 1} / ${state.totalPages}",
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(min = 120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { state.nextPage() }
|
||||
},
|
||||
enabled = state.currentPage < state.totalPages - 1 && !state.isLoading
|
||||
) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* A song list that supports drag-and-drop reordering when enabled.
|
||||
*
|
||||
* @param songs The current song list
|
||||
* @param reorderEnabled Whether drag-and-drop is enabled
|
||||
* @param onReorder Callback when songs are reordered, provides the new list
|
||||
*/
|
||||
@Composable
|
||||
fun ReorderableSongList(
|
||||
songs: List<SongEntry>,
|
||||
reorderEnabled: Boolean,
|
||||
onReorder: (List<SongEntry>) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Track drag state
|
||||
var draggedIndex by remember { mutableStateOf(-1) }
|
||||
var hoverIndex by remember { mutableStateOf(-1) }
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
|
||||
// Approximate item height for calculating target index from drag offset
|
||||
val itemHeightPx = 36f // approximate height of each row in pixels
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
itemsIndexed(songs, key = { _, song -> song.fileName }) { index, song ->
|
||||
val isDragTarget = hoverIndex == index && draggedIndex != -1 && draggedIndex != index
|
||||
val isBeingDragged = draggedIndex == index
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isDragTarget) {
|
||||
Modifier.background(Color(0xFFBBDEFB)) // light blue drop indicator
|
||||
} else if (isBeingDragged) {
|
||||
Modifier.background(Color(0xFFE0E0E0)) // grey for dragged item
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 8.dp)
|
||||
.then(
|
||||
if (reorderEnabled) {
|
||||
Modifier.pointerInput(songs) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggedIndex = index
|
||||
hoverIndex = index
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount.y
|
||||
// Calculate target index based on cumulative drag offset
|
||||
val indexDelta = (dragOffset / itemHeightPx).toInt()
|
||||
val newHover = (draggedIndex + indexDelta).coerceIn(0, songs.size - 1)
|
||||
hoverIndex = newHover
|
||||
},
|
||||
onDragEnd = {
|
||||
if (draggedIndex != -1 && hoverIndex != -1 && draggedIndex != hoverIndex) {
|
||||
val mutable = songs.toMutableList()
|
||||
val item = mutable.removeAt(draggedIndex)
|
||||
mutable.add(hoverIndex, item)
|
||||
onReorder(mutable)
|
||||
}
|
||||
draggedIndex = -1
|
||||
hoverIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = -1
|
||||
hoverIndex = -1
|
||||
dragOffset = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (reorderEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Ziehen zum Verschieben",
|
||||
modifier = Modifier.size(16.dp).padding(end = 4.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (!reorderEnabled && songs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Reihenfolge ist alphabetisch. Wechsle in songbook.yaml zu songs.order: \"manual\" um die Reihenfolge zu aendern.",
|
||||
color = Color.Gray,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -91,11 +91,11 @@ class MeasurementEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// Reference book footer: reserve space for abbreviation row + page number row + separator line
|
||||
// Reference book footer: gap + separator line + abbreviation row + page number row
|
||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size)
|
||||
heightMm += 4f * 0.3528f // gap before footer (4pt converted to mm)
|
||||
heightMm += metaLineHeight * 1.4f * 2 // two rows (headers + numbers)
|
||||
heightMm += metaLineHeight * 0.5f // separator line gap
|
||||
}
|
||||
|
||||
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
||||
|
||||
@@ -9,7 +9,12 @@ data class BookConfig(
|
||||
val referenceBooks: List<ReferenceBook> = 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(
|
||||
@@ -49,8 +54,8 @@ data class FontSpec(
|
||||
|
||||
data class LayoutConfig(
|
||||
val margins: Margins = Margins(),
|
||||
val chordLineSpacing: Float = 3f, // mm
|
||||
val verseSpacing: Float = 4f, // mm
|
||||
val chordLineSpacing: Float = 1f, // mm – gap between chord line and lyrics text
|
||||
val verseSpacing: Float = 6f, // mm – gap between consecutive song sections
|
||||
val pageNumberPosition: String = "bottom-outer",
|
||||
val metadataLabels: String = "abbreviated", // "abbreviated" (M:/T:) or "german" (Worte:/Weise:)
|
||||
val metadataPosition: String = "top" // "top" (after title) or "bottom" (bottom of last page)
|
||||
|
||||
@@ -14,6 +14,7 @@ sealed class PageContent {
|
||||
}
|
||||
|
||||
data class LayoutResult(
|
||||
val introPages: Int = 0,
|
||||
val tocPages: Int,
|
||||
val pages: List<PageContent>,
|
||||
val tocEntries: List<TocEntry>
|
||||
|
||||
@@ -24,6 +24,7 @@ object ChordProParser {
|
||||
var currentType: SectionType? = null
|
||||
var currentLabel: String? = null
|
||||
var currentLines = mutableListOf<SongLine>()
|
||||
var explicitSection = false
|
||||
|
||||
// Notes block state
|
||||
var inNotesBlock = false
|
||||
@@ -42,6 +43,7 @@ object ChordProParser {
|
||||
currentType = null
|
||||
currentLabel = null
|
||||
currentLines = mutableListOf()
|
||||
explicitSection = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +74,13 @@ object ChordProParser {
|
||||
// Skip comments
|
||||
if (line.trimStart().startsWith("#")) continue
|
||||
|
||||
// Skip empty lines
|
||||
if (line.isBlank()) continue
|
||||
// Blank line: flush implicit sections, skip otherwise
|
||||
if (line.isBlank()) {
|
||||
if (currentType != null && !explicitSection) {
|
||||
flushSection()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Directive line
|
||||
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||
@@ -109,6 +116,7 @@ object ChordProParser {
|
||||
flushSection()
|
||||
currentType = SectionType.VERSE
|
||||
currentLabel = value
|
||||
explicitSection = true
|
||||
}
|
||||
"end_of_verse", "eov" -> {
|
||||
flushSection()
|
||||
@@ -117,6 +125,7 @@ object ChordProParser {
|
||||
flushSection()
|
||||
currentType = SectionType.CHORUS
|
||||
currentLabel = value
|
||||
explicitSection = true
|
||||
}
|
||||
"end_of_chorus", "eoc" -> {
|
||||
flushSection()
|
||||
@@ -125,6 +134,7 @@ object ChordProParser {
|
||||
flushSection()
|
||||
currentType = SectionType.REPEAT
|
||||
currentLabel = value
|
||||
explicitSection = true
|
||||
}
|
||||
"end_of_repeat", "eor" -> {
|
||||
flushSection()
|
||||
|
||||
@@ -628,4 +628,145 @@ class ChordProParserTest {
|
||||
song.sections[0].lines[1].segments[0].text shouldBe "Some text"
|
||||
song.sections[0].lines[2].imagePath shouldBe "img2.png"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank line splits implicit verses into separate sections`() {
|
||||
val input = """
|
||||
{title: Am Brunnen vor dem Tore}
|
||||
|
||||
Am [D]Brunnen vor dem Tore
|
||||
Ich [D]träumt in seinem Schatten
|
||||
|
||||
Ich musst auch heute wandern
|
||||
Da hab ich noch im Dunkeln
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 2
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].lines shouldHaveSize 2
|
||||
song.sections[0].lines[0].segments shouldHaveSize 2
|
||||
song.sections[0].lines[0].segments[0].chord.shouldBeNull()
|
||||
song.sections[0].lines[0].segments[0].text shouldBe "Am "
|
||||
song.sections[0].lines[0].segments[1].chord shouldBe "D"
|
||||
song.sections[0].lines[0].segments[1].text shouldBe "Brunnen vor dem Tore"
|
||||
song.sections[1].type shouldBe SectionType.VERSE
|
||||
song.sections[1].lines shouldHaveSize 2
|
||||
song.sections[1].lines[0].segments[0].text shouldBe "Ich musst auch heute wandern"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank lines within explicit sections are ignored`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse: Verse 1}
|
||||
Line one
|
||||
|
||||
Line two
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].label shouldBe "Verse 1"
|
||||
song.sections[0].lines shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank lines between metadata do not create empty sections`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
|
||||
{lyricist: Someone}
|
||||
|
||||
{composer: Someone Else}
|
||||
|
||||
[Am]Hello world
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mixed explicit and implicit sections with blank lines`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_chorus}
|
||||
[C]Chorus line
|
||||
|
||||
Still in chorus
|
||||
{end_of_chorus}
|
||||
|
||||
Implicit verse one
|
||||
|
||||
Implicit verse two
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 3
|
||||
song.sections[0].type shouldBe SectionType.CHORUS
|
||||
song.sections[0].lines shouldHaveSize 2
|
||||
song.sections[1].type shouldBe SectionType.VERSE
|
||||
song.sections[1].lines shouldHaveSize 1
|
||||
song.sections[1].lines[0].segments[0].text shouldBe "Implicit verse one"
|
||||
song.sections[2].type shouldBe SectionType.VERSE
|
||||
song.sections[2].lines shouldHaveSize 1
|
||||
song.sections[2].lines[0].segments[0].text shouldBe "Implicit verse two"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple blank lines between implicit verses`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
First verse line
|
||||
|
||||
|
||||
Second verse line
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 2
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
song.sections[1].type shouldBe SectionType.VERSE
|
||||
song.sections[1].lines shouldHaveSize 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three implicit verses separated by blank lines`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
[Am]Verse one line one
|
||||
Verse one line two
|
||||
|
||||
[C]Verse two line one
|
||||
Verse two line two
|
||||
|
||||
[G]Verse three line one
|
||||
Verse three line two
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 3
|
||||
song.sections.forEach { section ->
|
||||
section.type shouldBe SectionType.VERSE
|
||||
section.lines shouldHaveSize 2
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank lines within explicit chorus are ignored`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_chorus}
|
||||
Line one
|
||||
|
||||
Line two
|
||||
|
||||
Line three
|
||||
{end_of_chorus}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.CHORUS
|
||||
song.sections[0].lines shouldHaveSize 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -63,7 +76,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 +104,165 @@ 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: gap + separator line + abbreviation row + page number row
|
||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||
val lineHeight = metaSize * 1.4f
|
||||
reserved += 4f // gap before footer
|
||||
reserved += lineHeight * 2 // two rows (headers + numbers)
|
||||
}
|
||||
|
||||
return reserved
|
||||
}
|
||||
|
||||
private fun renderSongPage(
|
||||
cb: PdfContentByte,
|
||||
chordLyricRenderer: ChordLyricRenderer,
|
||||
@@ -100,12 +272,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 +313,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 +332,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 +367,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 +382,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 +407,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,74 +422,81 @@ 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()) {
|
||||
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
|
||||
|
||||
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.showText(wrappedLine)
|
||||
cb.endText()
|
||||
y -= noteLineHeight
|
||||
}
|
||||
// Add paragraph spacing between note paragraphs
|
||||
if (idx < song.notes.size - 1) {
|
||||
y -= noteLineHeight * 0.3f
|
||||
}
|
||||
}
|
||||
}
|
||||
// The footer area spans from bottomMargin to bottomMargin + footerReservation.
|
||||
// Start rendering from the top of this area, flowing downward.
|
||||
var footerY = bottomMargin + footerReservation
|
||||
|
||||
// Render metadata at bottom of song page (if configured)
|
||||
if (renderMetaAtBottom && pageIndex == 0) {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
y -= 4f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (metaLine in metaParts) {
|
||||
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
|
||||
// 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 -= metaSize * 1.5f
|
||||
footerY -= noteLineHeight
|
||||
}
|
||||
if (idx < song.notes.size - 1) {
|
||||
footerY -= noteLineHeight * 0.3f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Render metadata (Worte/Weise) below notes, if configured at bottom
|
||||
if (renderMetaAtBottom) {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
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, footerY - metaSize)
|
||||
cb.showText(wrappedLine)
|
||||
cb.endText()
|
||||
footerY -= metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, contentWidth,
|
||||
config.layout.margins.bottom / 0.3528f
|
||||
leftMargin, footerY, contentWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -326,8 +532,9 @@ class PdfBookRenderer : BookRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders reference book abbreviations and page numbers as a footer row
|
||||
* at the bottom of the song page, above the page number.
|
||||
* Renders reference book abbreviations and page numbers as a footer
|
||||
* on the song page, positioned below the current content at [topY].
|
||||
* Layout (top to bottom): separator line, abbreviation row, page number row.
|
||||
*/
|
||||
private fun renderReferenceFooter(
|
||||
cb: PdfContentByte,
|
||||
@@ -335,40 +542,44 @@ class PdfBookRenderer : BookRenderer {
|
||||
config: BookConfig,
|
||||
song: Song,
|
||||
leftMargin: Float,
|
||||
contentWidth: Float,
|
||||
bottomMargin: Float
|
||||
topY: Float,
|
||||
contentWidth: 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
|
||||
// Draw a thin separator line at the top of the footer
|
||||
val lineY = topY
|
||||
cb.setLineWidth(0.3f)
|
||||
cb.setColorStroke(Color.LIGHT_GRAY)
|
||||
cb.moveTo(leftMargin, lineY)
|
||||
cb.lineTo(leftMargin + contentWidth, lineY)
|
||||
cb.stroke()
|
||||
|
||||
// Row 1: Abbreviation headers (below the separator line)
|
||||
val abbrY = lineY - lineHeight
|
||||
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.setTextMatrix(textX, abbrY)
|
||||
cb.showText(abbr)
|
||||
cb.endText()
|
||||
}
|
||||
|
||||
// Row 2: Page numbers
|
||||
// Row 2: Page numbers (below abbreviation headers)
|
||||
val numY = abbrY - lineHeight
|
||||
for ((i, book) in books.withIndex()) {
|
||||
val x = leftMargin + i * colWidth
|
||||
val pageNum = song.references[book.id]
|
||||
@@ -380,18 +591,11 @@ class PdfBookRenderer : BookRenderer {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(textX, footerY)
|
||||
cb.setTextMatrix(textX, numY)
|
||||
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(
|
||||
@@ -538,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)
|
||||
|
||||
@@ -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<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>) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ fonts:
|
||||
|
||||
layout:
|
||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||
chord_line_spacing: 3
|
||||
verse_spacing: 4
|
||||
chord_line_spacing: 1
|
||||
verse_spacing: 6
|
||||
page_number_position: bottom-outer
|
||||
|
||||
images:
|
||||
@@ -37,6 +37,9 @@ reference_books:
|
||||
name: "Pfadfinderliederbuch"
|
||||
abbreviation: "PfLB"
|
||||
|
||||
toc:
|
||||
highlight_column: "Seite"
|
||||
|
||||
output:
|
||||
directory: "./output"
|
||||
filename: "liederbuch.pdf"
|
||||
|
||||
Reference in New Issue
Block a user