Compare commits
18 Commits
v0.1.0-lat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d346e899d | ||
| 9056dbd9cd | |||
| 543fe66a44 | |||
| 032387c02d | |||
| a251fac053 | |||
| 077b3c027e | |||
| d733e83cb1 | |||
| 0f038a68d8 | |||
| a69d14033d | |||
| 0fb2771279 | |||
|
|
5378bdbc24 | ||
|
|
ab91ad2db6 | ||
|
|
b339c10ca0 | ||
|
|
8dca7d7131 | ||
|
|
8c92c7d78b | ||
|
|
0139327034 | ||
|
|
ba035159f7 | ||
|
|
8e4728c55a |
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`.
|
||||||
46
CLAUDE.md
46
CLAUDE.md
@@ -35,16 +35,17 @@ Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Pipeline:** Parse → Measure → Paginate → Render
|
**Pipeline:** Parse → Validate → Measure → Paginate → Render
|
||||||
|
|
||||||
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||||
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||||
2. `ChordProParser` reads `.chopro` files → `Song` objects
|
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
|
||||||
3. `Validator` checks config and songs
|
3. `ForewordParser` reads optional `foreword.txt` → `Foreword` (if configured)
|
||||||
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
4. `Validator` checks config and songs
|
||||||
5. `TocGenerator` estimates TOC page count and creates entries
|
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||||
6. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
6. `TocGenerator` estimates TOC page count and creates entries
|
||||||
7. `PdfBookRenderer` generates the PDF via OpenPDF
|
7. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||||
|
8. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||||
|
|
||||||
**Module dependency graph:**
|
**Module dependency graph:**
|
||||||
```
|
```
|
||||||
@@ -62,14 +63,39 @@ app, parser ← gui (Compose Desktop)
|
|||||||
|
|
||||||
## Key Types
|
## Key Types
|
||||||
|
|
||||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment. Also has `aliases`, `lyricist`, `composer`, `key`, `tags`, `notes: List<String>`, `references: Map<String, Int>` (bookId → page), `capo`
|
||||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image)
|
||||||
|
- `Foreword` — `quote`, `paragraphs`, `signatures` — parsed from a plain-text file
|
||||||
|
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage`
|
||||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||||
|
- `BookConfig` — top-level config with `FontsConfig`, `LayoutConfig`, `TocConfig`, `ForewordConfig`, `ReferenceBook` list. `FontSpec.file` supports custom font files. `LayoutConfig.metadataLabels` (`"abbreviated"` or `"german"`) and `metadataPosition` (`"top"` or `"bottom"`) control metadata rendering
|
||||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||||
|
|
||||||
## Song Format
|
## Song Format
|
||||||
|
|
||||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
ChordPro-compatible `.chopro`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||||
|
|
||||||
|
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }`
|
||||||
|
|
||||||
|
**Section directives:** `{start_of_verse}` / `{sov}`, `{end_of_verse}` / `{eov}`, `{start_of_chorus}` / `{soc}`, `{end_of_chorus}` / `{eoc}`, `{start_of_repeat}` / `{sor}`, `{end_of_repeat}` / `{eor}`. Section starts accept an optional label. `{chorus}` inserts a chorus reference, `{repeat}` sets a repeat label.
|
||||||
|
|
||||||
|
**Notes block:** `{start_of_notes}` / `{son}` … `{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song.
|
||||||
|
|
||||||
|
**Inline image:** `{image: path}` — embeds an image within a song section.
|
||||||
|
|
||||||
|
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`songbook.yaml` at the project root. Key options beyond the basics:
|
||||||
|
|
||||||
|
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`)
|
||||||
|
- `layout.metadata_labels` — `"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:)
|
||||||
|
- `layout.metadata_position` — `"top"` (after title) or `"bottom"` (bottom of last page)
|
||||||
|
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`)
|
||||||
|
- `foreword.file` — path to a foreword text file (default `./foreword.txt`)
|
||||||
|
- `reference_books` — list of `{id, name, abbreviation}` for cross-reference columns in the TOC
|
||||||
|
- `songs.order` — `"alphabetical"` or `"manual"` (file-system order)
|
||||||
|
|
||||||
## Test Patterns
|
## Test Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package de.pfadfinder.songbook.app
|
|||||||
|
|
||||||
import de.pfadfinder.songbook.model.*
|
import de.pfadfinder.songbook.model.*
|
||||||
import de.pfadfinder.songbook.parser.*
|
import de.pfadfinder.songbook.parser.*
|
||||||
|
import de.pfadfinder.songbook.parser.ForewordParser
|
||||||
import de.pfadfinder.songbook.layout.*
|
import de.pfadfinder.songbook.layout.*
|
||||||
import de.pfadfinder.songbook.renderer.pdf.*
|
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer
|
||||||
|
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
|
||||||
|
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -20,16 +23,29 @@ data class BuildResult(
|
|||||||
|
|
||||||
class SongbookPipeline(private val projectDir: File) {
|
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
|
// 1. Parse config
|
||||||
|
onProgress?.invoke("Konfiguration wird geladen...")
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
if (!configFile.exists()) {
|
if (!configFile.exists()) {
|
||||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||||
}
|
}
|
||||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||||
val config = ConfigParser.parse(configFile)
|
val rawConfig = ConfigParser.parse(configFile)
|
||||||
|
|
||||||
// Validate config
|
// Resolve font file paths relative to the project directory
|
||||||
|
val config = resolveFontPaths(rawConfig)
|
||||||
|
|
||||||
|
// Validate config (including font file existence)
|
||||||
val configErrors = Validator.validateConfig(config)
|
val configErrors = Validator.validateConfig(config)
|
||||||
if (configErrors.isNotEmpty()) {
|
if (configErrors.isNotEmpty()) {
|
||||||
return BuildResult(false, errors = configErrors)
|
return BuildResult(false, errors = configErrors)
|
||||||
@@ -49,19 +65,23 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...")
|
||||||
logger.info { "Found ${songFiles.size} song files" }
|
logger.info { "Found ${songFiles.size} song files" }
|
||||||
|
|
||||||
val songs = mutableListOf<Song>()
|
val songsByFileName = mutableMapOf<String, Song>()
|
||||||
val allErrors = mutableListOf<ValidationError>()
|
val allErrors = mutableListOf<ValidationError>()
|
||||||
|
|
||||||
for (file in songFiles) {
|
for ((index, file) in songFiles.withIndex()) {
|
||||||
|
if (index > 0 && index % 50 == 0) {
|
||||||
|
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val song = ChordProParser.parseFile(file)
|
val song = ChordProParser.parseFile(file)
|
||||||
val songErrors = Validator.validateSong(song, file.name)
|
val songErrors = Validator.validateSong(song, file.name)
|
||||||
if (songErrors.isNotEmpty()) {
|
if (songErrors.isNotEmpty()) {
|
||||||
allErrors.addAll(songErrors)
|
allErrors.addAll(songErrors)
|
||||||
} else {
|
} else {
|
||||||
songs.add(song)
|
songsByFileName[file.name] = song
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||||
@@ -72,37 +92,92 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
return BuildResult(false, errors = allErrors)
|
return BuildResult(false, errors = allErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort songs
|
val songs = songsByFileName.values.toList()
|
||||||
val sortedSongs = when (config.songs.order) {
|
|
||||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
// Sort songs: custom order takes priority, then config-based sort
|
||||||
else -> songs // manual order = file order
|
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" }
|
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||||
|
|
||||||
|
// 2b. Parse foreword (if configured)
|
||||||
|
var foreword: Foreword? = null
|
||||||
|
val forewordConfig = config.foreword
|
||||||
|
if (forewordConfig != null) {
|
||||||
|
val forewordFile = File(projectDir, forewordConfig.file)
|
||||||
|
if (forewordFile.exists()) {
|
||||||
|
logger.info { "Parsing foreword: ${forewordFile.absolutePath}" }
|
||||||
|
foreword = ForewordParser.parseFile(forewordFile)
|
||||||
|
} else {
|
||||||
|
logger.warn { "Foreword file not found: ${forewordFile.absolutePath}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Measure songs
|
// 3. Measure songs
|
||||||
|
onProgress?.invoke("Layout wird berechnet...")
|
||||||
val fontMetrics = PdfFontMetrics()
|
val fontMetrics = PdfFontMetrics()
|
||||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||||
|
|
||||||
// 4. Generate TOC and paginate
|
// 4. Generate TOC and paginate
|
||||||
val tocGenerator = TocGenerator(config)
|
val tocGenerator = TocGenerator(config)
|
||||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
val estimatedTocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||||
|
|
||||||
|
// Intro page takes 2 pages (title + blank back) for double-sided printing
|
||||||
|
val introPages = if (config.intro?.enabled == true) 2 else 0
|
||||||
|
// Foreword always takes 2 pages (for double-sided printing)
|
||||||
|
val forewordPages = if (foreword != null) 2 else 0
|
||||||
|
|
||||||
|
val headerPages = introPages + estimatedTocPages + forewordPages
|
||||||
val paginationEngine = PaginationEngine(config)
|
val paginationEngine = PaginationEngine(config)
|
||||||
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
val pages = paginationEngine.paginate(measuredSongs, headerPages)
|
||||||
|
|
||||||
val tocEntries = tocGenerator.generate(pages, tocPages)
|
// 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>()
|
||||||
|
if (foreword != null) {
|
||||||
|
allPages.add(PageContent.ForewordPage(foreword, 0))
|
||||||
|
allPages.add(PageContent.ForewordPage(foreword, 1))
|
||||||
|
}
|
||||||
|
allPages.addAll(pages)
|
||||||
|
|
||||||
val layoutResult = LayoutResult(
|
val layoutResult = LayoutResult(
|
||||||
|
introPages = introPages,
|
||||||
tocPages = tocPages,
|
tocPages = tocPages,
|
||||||
pages = pages,
|
pages = allPages,
|
||||||
tocEntries = tocEntries
|
tocEntries = tocEntries
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
val totalPages = introPages + tocPages + pages.size
|
||||||
|
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
|
||||||
|
|
||||||
// 5. Render PDF
|
// 5. Render PDF
|
||||||
|
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
|
||||||
val outputDir = File(projectDir, config.output.directory)
|
val outputDir = File(projectDir, config.output.directory)
|
||||||
outputDir.mkdirs()
|
outputDir.mkdirs()
|
||||||
val outputFile = File(outputDir, config.output.filename)
|
val outputFile = File(outputDir, config.output.filename)
|
||||||
@@ -114,23 +189,49 @@ class SongbookPipeline(private val projectDir: File) {
|
|||||||
renderer.render(layoutResult, config, fos)
|
renderer.render(layoutResult, config, fos)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
logger.info { "Build complete: ${sortedSongs.size} songs, $totalPages pages" }
|
||||||
|
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success = true,
|
success = true,
|
||||||
outputFile = outputFile,
|
outputFile = outputFile,
|
||||||
songCount = sortedSongs.size,
|
songCount = sortedSongs.size,
|
||||||
pageCount = pages.size + tocPages
|
pageCount = totalPages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves font file paths relative to the project directory.
|
||||||
|
* If a FontSpec has a `file` property, it is resolved against projectDir
|
||||||
|
* to produce an absolute path.
|
||||||
|
*/
|
||||||
|
private fun resolveFontPaths(config: BookConfig): BookConfig {
|
||||||
|
fun FontSpec.resolveFile(): FontSpec {
|
||||||
|
val fontFile = this.file ?: return this
|
||||||
|
val fontFileObj = File(fontFile)
|
||||||
|
// Only resolve relative paths; absolute paths are left as-is
|
||||||
|
if (fontFileObj.isAbsolute) return this
|
||||||
|
val resolved = File(projectDir, fontFile)
|
||||||
|
return this.copy(file = resolved.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedFonts = config.fonts.copy(
|
||||||
|
lyrics = config.fonts.lyrics.resolveFile(),
|
||||||
|
chords = config.fonts.chords.resolveFile(),
|
||||||
|
title = config.fonts.title.resolveFile(),
|
||||||
|
metadata = config.fonts.metadata.resolveFile(),
|
||||||
|
toc = config.fonts.toc.resolveFile()
|
||||||
|
)
|
||||||
|
return config.copy(fonts = resolvedFonts)
|
||||||
|
}
|
||||||
|
|
||||||
fun validate(): List<ValidationError> {
|
fun validate(): List<ValidationError> {
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
if (!configFile.exists()) {
|
if (!configFile.exists()) {
|
||||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = ConfigParser.parse(configFile)
|
val rawConfig = ConfigParser.parse(configFile)
|
||||||
|
val config = resolveFontPaths(rawConfig)
|
||||||
val errors = mutableListOf<ValidationError>()
|
val errors = mutableListOf<ValidationError>()
|
||||||
errors.addAll(Validator.validateConfig(config))
|
errors.addAll(Validator.validateConfig(config))
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ plugins {
|
|||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class BuildCommand : CliktCommand(name = "build") {
|
|||||||
echo("Building songbook from: ${dir.path}")
|
echo("Building songbook from: ${dir.path}")
|
||||||
|
|
||||||
val pipeline = SongbookPipeline(dir)
|
val pipeline = SongbookPipeline(dir)
|
||||||
val result = pipeline.build()
|
val result = pipeline.build(onProgress = { msg -> echo(msg) })
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
echo("Build successful!")
|
echo("Build successful!")
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
||||||
|
|||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -11,6 +11,7 @@ dependencies {
|
|||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||||
|
implementation("org.apache.pdfbox:pdfbox:3.0.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.pfadfinder.songbook.gui
|
package de.pfadfinder.songbook.gui
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.VerticalScrollbar
|
import androidx.compose.foundation.VerticalScrollbar
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.BuildResult
|
||||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||||
import de.pfadfinder.songbook.parser.ChordProParser
|
import de.pfadfinder.songbook.parser.ChordProParser
|
||||||
|
import de.pfadfinder.songbook.parser.ConfigParser
|
||||||
import de.pfadfinder.songbook.parser.ValidationError
|
import de.pfadfinder.songbook.parser.ValidationError
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -44,47 +46,83 @@ data class SongEntry(val fileName: String, val title: String)
|
|||||||
fun App() {
|
fun App() {
|
||||||
var projectPath by remember { mutableStateOf("") }
|
var projectPath by remember { mutableStateOf("") }
|
||||||
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
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 statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||||
var isRunning by remember { mutableStateOf(false) }
|
var isRunning by remember { mutableStateOf(false) }
|
||||||
|
var isLoadingSongs by remember { mutableStateOf(false) }
|
||||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||||
|
val previewState = remember { PdfPreviewState() }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val reorderEnabled = songsOrderConfig != "alphabetical"
|
||||||
|
|
||||||
fun loadSongs(path: String) {
|
fun loadSongs(path: String) {
|
||||||
val projectDir = File(path)
|
val projectDir = File(path)
|
||||||
songs = emptyList()
|
songs = emptyList()
|
||||||
|
originalSongs = emptyList()
|
||||||
|
isCustomOrder = false
|
||||||
if (!projectDir.isDirectory) return
|
if (!projectDir.isDirectory) return
|
||||||
|
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
isLoadingSongs = true
|
||||||
val songsDir = if (configFile.exists()) {
|
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
|
||||||
try {
|
|
||||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
scope.launch {
|
||||||
File(projectDir, config.songs.directory)
|
val (loadedSongs, order) = withContext(Dispatchers.IO) {
|
||||||
} catch (_: Exception) {
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
File(projectDir, "songs")
|
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
|
songsOrderConfig = order
|
||||||
|
songs = if (order == "alphabetical") {
|
||||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
loadedSongs.sortedBy { it.title.lowercase() }
|
||||||
?.sortedBy { it.name }
|
} else {
|
||||||
?: emptyList()
|
loadedSongs
|
||||||
|
}
|
||||||
songs = songFiles.mapNotNull { file ->
|
originalSongs = songs.toList()
|
||||||
try {
|
isLoadingSongs = false
|
||||||
val song = ChordProParser.parseFile(file)
|
statusMessages = if (loadedSongs.isNotEmpty()) {
|
||||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
|
||||||
} catch (_: Exception) {
|
} else {
|
||||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SelectionContainer {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
// Project directory selection
|
// Project directory selection
|
||||||
Text(
|
Text(
|
||||||
@@ -130,48 +168,76 @@ fun App() {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Song list
|
// Central content area: song list or preview panel
|
||||||
Text(
|
if (previewState.isVisible) {
|
||||||
text = "Lieder (${songs.size}):",
|
// Show preview panel
|
||||||
fontWeight = FontWeight.Medium
|
PdfPreviewPanel(
|
||||||
)
|
state = previewState,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
} else {
|
||||||
val listState = rememberLazyListState()
|
// Song list header with optional reset button
|
||||||
LazyColumn(
|
Row(
|
||||||
state = listState,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
Text(
|
||||||
item {
|
text = "Lieder (${songs.size}):",
|
||||||
Text(
|
fontWeight = FontWeight.Medium,
|
||||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
modifier = Modifier.weight(1f)
|
||||||
color = Color.Gray,
|
)
|
||||||
modifier = Modifier.padding(8.dp)
|
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(
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
|
||||||
adapter = rememberScrollbarAdapter(listState)
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -185,9 +251,17 @@ fun App() {
|
|||||||
lastBuildResult = null
|
lastBuildResult = null
|
||||||
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
||||||
scope.launch {
|
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) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
SongbookPipeline(File(projectPath)).build()
|
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
|
||||||
|
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
BuildResult(
|
BuildResult(
|
||||||
success = false,
|
success = false,
|
||||||
@@ -220,6 +294,11 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
|
// Automatically load preview after successful build
|
||||||
|
if (result.success && result.outputFile != null) {
|
||||||
|
previewState.loadPdf(result.outputFile!!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isRunning && projectPath.isNotBlank()
|
enabled = !isRunning && projectPath.isNotBlank()
|
||||||
@@ -232,7 +311,7 @@ fun App() {
|
|||||||
if (projectPath.isBlank()) return@Button
|
if (projectPath.isBlank()) return@Button
|
||||||
isRunning = true
|
isRunning = true
|
||||||
lastBuildResult = null
|
lastBuildResult = null
|
||||||
statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
|
statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO))
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val errors = withContext(Dispatchers.IO) {
|
val errors = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -271,7 +350,7 @@ fun App() {
|
|||||||
Desktop.getDesktop().open(file)
|
Desktop.getDesktop().open(file)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
statusMessages = statusMessages + StatusMessage(
|
statusMessages = statusMessages + StatusMessage(
|
||||||
"PDF konnte nicht geöffnet werden: ${e.message}",
|
"PDF konnte nicht geoeffnet werden: ${e.message}",
|
||||||
MessageType.ERROR
|
MessageType.ERROR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -279,7 +358,27 @@ fun App() {
|
|||||||
},
|
},
|
||||||
enabled = !isRunning
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,16 @@ class MeasurementEngine(
|
|||||||
// Title height
|
// Title height
|
||||||
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
||||||
|
|
||||||
// Metadata line (composer/lyricist)
|
// Metadata lines (composer/lyricist) - may be 1 or 2 lines depending on label style
|
||||||
if (song.composer != null || song.lyricist != null) {
|
if (song.composer != null || song.lyricist != null) {
|
||||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||||
|
val useGerman = config.layout.metadataLabels == "german"
|
||||||
|
if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) {
|
||||||
|
// Two separate lines: "Worte: ..." and "Weise: ..."
|
||||||
|
heightMm += metaLineHeight * 2
|
||||||
|
} else {
|
||||||
|
heightMm += metaLineHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key/capo line
|
// Key/capo line
|
||||||
@@ -43,29 +50,54 @@ class MeasurementEngine(
|
|||||||
|
|
||||||
// Lines in section
|
// Lines in section
|
||||||
for (line in section.lines) {
|
for (line in section.lines) {
|
||||||
val hasChords = line.segments.any { it.chord != null }
|
if (line.imagePath != null) {
|
||||||
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
// Inline image: estimate height as 40mm (default image block height)
|
||||||
if (hasChords) {
|
heightMm += 40f
|
||||||
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
|
heightMm += 2f // gap around image
|
||||||
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
|
|
||||||
} else {
|
} else {
|
||||||
heightMm += lyricHeight
|
val hasChords = line.segments.any { it.chord != null }
|
||||||
|
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
||||||
|
if (hasChords) {
|
||||||
|
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
|
||||||
|
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
|
||||||
|
} else {
|
||||||
|
heightMm += lyricHeight
|
||||||
|
}
|
||||||
|
heightMm += 0.35f // ~1pt gap between lines
|
||||||
}
|
}
|
||||||
heightMm += 0.35f // ~1pt gap between lines
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verse spacing
|
// Verse spacing
|
||||||
heightMm += config.layout.verseSpacing
|
heightMm += config.layout.verseSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes at bottom
|
// Notes at bottom (with word-wrap estimation for multi-paragraph notes)
|
||||||
if (song.notes.isNotEmpty()) {
|
if (song.notes.isNotEmpty()) {
|
||||||
heightMm += 1.5f // gap
|
heightMm += 1.5f // gap before notes
|
||||||
for (note in song.notes) {
|
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
// A5 content width in mm = 148 - inner margin - outer margin
|
||||||
|
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
|
||||||
|
|
||||||
|
for ((idx, note) in song.notes.withIndex()) {
|
||||||
|
// Estimate how many wrapped lines this note paragraph needs
|
||||||
|
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
|
||||||
|
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
|
||||||
|
heightMm += metaLineHeight * estimatedLines
|
||||||
|
|
||||||
|
// Paragraph spacing between note paragraphs
|
||||||
|
if (idx < song.notes.size - 1) {
|
||||||
|
heightMm += metaLineHeight * 0.3f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
||||||
return MeasuredSong(song, heightMm, pageCount)
|
return MeasuredSong(song, heightMm, pageCount)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,4 +258,106 @@ class MeasurementEngineTest {
|
|||||||
|
|
||||||
labeledHeight shouldBeGreaterThan unlabeledHeight
|
labeledHeight shouldBeGreaterThan unlabeledHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `references add footer height when reference books configured`() {
|
||||||
|
val configWithRefs = BookConfig(
|
||||||
|
referenceBooks = listOf(
|
||||||
|
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
|
||||||
|
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val engineWithRefs = MeasurementEngine(fontMetrics, configWithRefs)
|
||||||
|
|
||||||
|
val songWithRefs = Song(
|
||||||
|
title = "With Refs",
|
||||||
|
references = mapOf("mo" to 42, "pl" to 17),
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutRefs = Song(
|
||||||
|
title = "No Refs",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val heightWith = engineWithRefs.measure(songWithRefs).totalHeightMm
|
||||||
|
val heightWithout = engineWithRefs.measure(songWithoutRefs).totalHeightMm
|
||||||
|
|
||||||
|
heightWith shouldBeGreaterThan heightWithout
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `references do not add height when no reference books configured`() {
|
||||||
|
val songWithRefs = Song(
|
||||||
|
title = "With Refs",
|
||||||
|
references = mapOf("mo" to 42),
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutRefs = Song(
|
||||||
|
title = "No Refs",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default config has no reference books
|
||||||
|
val heightWith = engine.measure(songWithRefs).totalHeightMm
|
||||||
|
val heightWithout = engine.measure(songWithoutRefs).totalHeightMm
|
||||||
|
|
||||||
|
// Should be the same since no reference books are configured
|
||||||
|
heightWith shouldBe heightWithout
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inline image adds significant height`() {
|
||||||
|
val songWithImage = Song(
|
||||||
|
title = "With Image",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(
|
||||||
|
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||||
|
SongLine(imagePath = "images/test.png"),
|
||||||
|
SongLine(listOf(LineSegment(text = "Line after")))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val songWithoutImage = Song(
|
||||||
|
title = "No Image",
|
||||||
|
sections = listOf(
|
||||||
|
SongSection(
|
||||||
|
type = SectionType.VERSE,
|
||||||
|
lines = listOf(
|
||||||
|
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||||
|
SongLine(listOf(LineSegment(text = "Line after")))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val heightWith = engine.measure(songWithImage).totalHeightMm
|
||||||
|
val heightWithout = engine.measure(songWithoutImage).totalHeightMm
|
||||||
|
|
||||||
|
// Inline image adds ~42mm (40mm image + 2mm gap)
|
||||||
|
val diff = heightWith - heightWithout
|
||||||
|
diff shouldBeGreaterThan 30f // should be substantial
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,22 @@ data class BookConfig(
|
|||||||
val layout: LayoutConfig = LayoutConfig(),
|
val layout: LayoutConfig = LayoutConfig(),
|
||||||
val images: ImagesConfig = ImagesConfig(),
|
val images: ImagesConfig = ImagesConfig(),
|
||||||
val referenceBooks: List<ReferenceBook> = emptyList(),
|
val referenceBooks: List<ReferenceBook> = emptyList(),
|
||||||
val output: OutputConfig = OutputConfig()
|
val output: OutputConfig = OutputConfig(),
|
||||||
|
val foreword: ForewordConfig? = null,
|
||||||
|
val toc: TocConfig = TocConfig(),
|
||||||
|
val intro: IntroConfig? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class IntroConfig(
|
||||||
|
val enabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TocConfig(
|
||||||
|
val highlightColumn: String? = null // abbreviation of the column to highlight (e.g. "CL")
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ForewordConfig(
|
||||||
|
val file: String = "./foreword.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BookMeta(
|
data class BookMeta(
|
||||||
@@ -39,9 +54,11 @@ data class FontSpec(
|
|||||||
|
|
||||||
data class LayoutConfig(
|
data class LayoutConfig(
|
||||||
val margins: Margins = Margins(),
|
val margins: Margins = Margins(),
|
||||||
val chordLineSpacing: Float = 3f, // mm
|
val chordLineSpacing: Float = 1f, // mm – gap between chord line and lyrics text
|
||||||
val verseSpacing: Float = 4f, // mm
|
val verseSpacing: Float = 6f, // mm – gap between consecutive song sections
|
||||||
val pageNumberPosition: String = "bottom-outer"
|
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Margins(
|
data class Margins(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.pfadfinder.songbook.model
|
||||||
|
|
||||||
|
data class Foreword(
|
||||||
|
val quote: String? = null,
|
||||||
|
val paragraphs: List<String> = emptyList(),
|
||||||
|
val signatures: List<String> = emptyList()
|
||||||
|
)
|
||||||
@@ -10,9 +10,11 @@ sealed class PageContent {
|
|||||||
data class SongPage(val song: Song, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for 2-page songs
|
data class SongPage(val song: Song, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for 2-page songs
|
||||||
data class FillerImage(val imagePath: String) : PageContent()
|
data class FillerImage(val imagePath: String) : PageContent()
|
||||||
data object BlankPage : PageContent()
|
data object BlankPage : PageContent()
|
||||||
|
data class ForewordPage(val foreword: Foreword, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for multi-page forewords
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LayoutResult(
|
data class LayoutResult(
|
||||||
|
val introPages: Int = 0,
|
||||||
val tocPages: Int,
|
val tocPages: Int,
|
||||||
val pages: List<PageContent>,
|
val pages: List<PageContent>,
|
||||||
val tocEntries: List<TocEntry>
|
val tocEntries: List<TocEntry>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ enum class SectionType {
|
|||||||
VERSE, CHORUS, BRIDGE, REPEAT
|
VERSE, CHORUS, BRIDGE, REPEAT
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SongLine(val segments: List<LineSegment>)
|
data class SongLine(
|
||||||
|
val segments: List<LineSegment> = emptyList(),
|
||||||
|
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
|
||||||
|
)
|
||||||
|
|
||||||
data class LineSegment(
|
data class LineSegment(
|
||||||
val chord: String? = null, // null = no chord above this segment
|
val chord: String? = null, // null = no chord above this segment
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ object ChordProParser {
|
|||||||
var currentType: SectionType? = null
|
var currentType: SectionType? = null
|
||||||
var currentLabel: String? = null
|
var currentLabel: String? = null
|
||||||
var currentLines = mutableListOf<SongLine>()
|
var currentLines = mutableListOf<SongLine>()
|
||||||
|
var explicitSection = false
|
||||||
|
|
||||||
|
// Notes block state
|
||||||
|
var inNotesBlock = false
|
||||||
|
var currentNoteParagraph = StringBuilder()
|
||||||
|
|
||||||
|
fun flushNoteParagraph() {
|
||||||
|
if (currentNoteParagraph.isNotEmpty()) {
|
||||||
|
notes.add(currentNoteParagraph.toString().trim())
|
||||||
|
currentNoteParagraph = StringBuilder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun flushSection() {
|
fun flushSection() {
|
||||||
if (currentType != null) {
|
if (currentType != null) {
|
||||||
@@ -31,17 +43,44 @@ object ChordProParser {
|
|||||||
currentType = null
|
currentType = null
|
||||||
currentLabel = null
|
currentLabel = null
|
||||||
currentLines = mutableListOf()
|
currentLines = mutableListOf()
|
||||||
|
explicitSection = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (rawLine in lines) {
|
for (rawLine in lines) {
|
||||||
val line = rawLine.trimEnd()
|
val line = rawLine.trimEnd()
|
||||||
|
|
||||||
|
// Inside a notes block: collect lines as paragraphs
|
||||||
|
if (inNotesBlock) {
|
||||||
|
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||||
|
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
|
||||||
|
if (inner == "end_of_notes" || inner == "eon") {
|
||||||
|
flushNoteParagraph()
|
||||||
|
inNotesBlock = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.isBlank()) {
|
||||||
|
flushNoteParagraph()
|
||||||
|
} else {
|
||||||
|
if (currentNoteParagraph.isNotEmpty()) {
|
||||||
|
currentNoteParagraph.append(" ")
|
||||||
|
}
|
||||||
|
currentNoteParagraph.append(line.trim())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Skip comments
|
// Skip comments
|
||||||
if (line.trimStart().startsWith("#")) continue
|
if (line.trimStart().startsWith("#")) continue
|
||||||
|
|
||||||
// Skip empty lines
|
// Blank line: flush implicit sections, skip otherwise
|
||||||
if (line.isBlank()) continue
|
if (line.isBlank()) {
|
||||||
|
if (currentType != null && !explicitSection) {
|
||||||
|
flushSection()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Directive line
|
// Directive line
|
||||||
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||||
@@ -77,6 +116,7 @@ object ChordProParser {
|
|||||||
flushSection()
|
flushSection()
|
||||||
currentType = SectionType.VERSE
|
currentType = SectionType.VERSE
|
||||||
currentLabel = value
|
currentLabel = value
|
||||||
|
explicitSection = true
|
||||||
}
|
}
|
||||||
"end_of_verse", "eov" -> {
|
"end_of_verse", "eov" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
@@ -85,6 +125,7 @@ object ChordProParser {
|
|||||||
flushSection()
|
flushSection()
|
||||||
currentType = SectionType.CHORUS
|
currentType = SectionType.CHORUS
|
||||||
currentLabel = value
|
currentLabel = value
|
||||||
|
explicitSection = true
|
||||||
}
|
}
|
||||||
"end_of_chorus", "eoc" -> {
|
"end_of_chorus", "eoc" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
@@ -93,10 +134,26 @@ object ChordProParser {
|
|||||||
flushSection()
|
flushSection()
|
||||||
currentType = SectionType.REPEAT
|
currentType = SectionType.REPEAT
|
||||||
currentLabel = value
|
currentLabel = value
|
||||||
|
explicitSection = true
|
||||||
}
|
}
|
||||||
"end_of_repeat", "eor" -> {
|
"end_of_repeat", "eor" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
}
|
}
|
||||||
|
"image" -> if (value != null) {
|
||||||
|
// Inline image within a song section
|
||||||
|
if (currentType == null) {
|
||||||
|
currentType = SectionType.VERSE
|
||||||
|
}
|
||||||
|
currentLines.add(SongLine(imagePath = value.trim()))
|
||||||
|
}
|
||||||
|
"start_of_notes", "son" -> {
|
||||||
|
inNotesBlock = true
|
||||||
|
}
|
||||||
|
"end_of_notes", "eon" -> {
|
||||||
|
// Should have been handled in the notes block above
|
||||||
|
flushNoteParagraph()
|
||||||
|
inNotesBlock = false
|
||||||
|
}
|
||||||
"chorus" -> {
|
"chorus" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
sections.add(SongSection(type = SectionType.CHORUS))
|
sections.add(SongSection(type = SectionType.CHORUS))
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.pfadfinder.songbook.parser
|
||||||
|
|
||||||
|
import de.pfadfinder.songbook.model.Foreword
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object ForewordParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a foreword text file into a [Foreword] object.
|
||||||
|
*
|
||||||
|
* Format:
|
||||||
|
* - Lines starting with `> ` are collected as the quote (multiple lines joined)
|
||||||
|
* - `---` is a horizontal rule separator (marks end of quote section)
|
||||||
|
* - Lines starting with `-- ` are signatures
|
||||||
|
* - Other non-blank lines are body text; blank lines separate paragraphs
|
||||||
|
*/
|
||||||
|
fun parse(input: String): Foreword {
|
||||||
|
val lines = input.lines()
|
||||||
|
|
||||||
|
val quoteLines = mutableListOf<String>()
|
||||||
|
val signatures = mutableListOf<String>()
|
||||||
|
val paragraphs = mutableListOf<String>()
|
||||||
|
|
||||||
|
var currentParagraph = StringBuilder()
|
||||||
|
var inQuote = true // Start assuming we might be in the quote section
|
||||||
|
var foundSeparator = false
|
||||||
|
|
||||||
|
for (rawLine in lines) {
|
||||||
|
val line = rawLine.trimEnd()
|
||||||
|
|
||||||
|
// Quote lines (before separator)
|
||||||
|
if (!foundSeparator && line.trimStart().startsWith("> ")) {
|
||||||
|
quoteLines.add(line.trimStart().removePrefix("> "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we had quote lines but now see non-quote content before separator,
|
||||||
|
// the quote section is done
|
||||||
|
if (!foundSeparator && quoteLines.isNotEmpty() && line.trimStart().isNotEmpty() && !line.trimStart().startsWith("> ")) {
|
||||||
|
if (line.trim() == "---") {
|
||||||
|
foundSeparator = true
|
||||||
|
inQuote = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
if (line.trim() == "---") {
|
||||||
|
foundSeparator = true
|
||||||
|
inQuote = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature lines
|
||||||
|
if (line.trimStart().startsWith("-- ")) {
|
||||||
|
signatures.add(line.trimStart().removePrefix("-- "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip quote processing after we established there are no quotes
|
||||||
|
if (inQuote && quoteLines.isEmpty() && line.isBlank()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inQuote = false
|
||||||
|
|
||||||
|
// Body paragraphs
|
||||||
|
if (line.isBlank()) {
|
||||||
|
if (currentParagraph.isNotEmpty()) {
|
||||||
|
paragraphs.add(currentParagraph.toString().trim())
|
||||||
|
currentParagraph = StringBuilder()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentParagraph.isNotEmpty()) {
|
||||||
|
currentParagraph.append(" ")
|
||||||
|
}
|
||||||
|
currentParagraph.append(line.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining paragraph
|
||||||
|
if (currentParagraph.isNotEmpty()) {
|
||||||
|
paragraphs.add(currentParagraph.toString().trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
val quote = if (quoteLines.isNotEmpty()) quoteLines.joinToString(" ") else null
|
||||||
|
|
||||||
|
return Foreword(
|
||||||
|
quote = quote,
|
||||||
|
paragraphs = paragraphs,
|
||||||
|
signatures = signatures
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseFile(file: File): Foreword = parse(file.readText())
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.pfadfinder.songbook.parser
|
package de.pfadfinder.songbook.parser
|
||||||
|
|
||||||
import de.pfadfinder.songbook.model.BookConfig
|
import de.pfadfinder.songbook.model.BookConfig
|
||||||
|
import de.pfadfinder.songbook.model.FontSpec
|
||||||
import de.pfadfinder.songbook.model.Song
|
import de.pfadfinder.songbook.model.Song
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class ValidationError(val file: String?, val line: Int?, val message: String)
|
data class ValidationError(val file: String?, val line: Int?, val message: String)
|
||||||
|
|
||||||
@@ -50,6 +52,27 @@ object Validator {
|
|||||||
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
|
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate font files exist (paths should already be resolved to absolute by the pipeline)
|
||||||
|
validateFontFile(config.fonts.lyrics, "lyrics", errors)
|
||||||
|
validateFontFile(config.fonts.chords, "chords", errors)
|
||||||
|
validateFontFile(config.fonts.title, "title", errors)
|
||||||
|
validateFontFile(config.fonts.metadata, "metadata", errors)
|
||||||
|
validateFontFile(config.fonts.toc, "toc", errors)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateFontFile(font: FontSpec, fontRole: String, errors: MutableList<ValidationError>) {
|
||||||
|
val fontFile = font.file ?: return
|
||||||
|
val file = File(fontFile)
|
||||||
|
if (!file.exists()) {
|
||||||
|
errors.add(
|
||||||
|
ValidationError(
|
||||||
|
file = null,
|
||||||
|
line = null,
|
||||||
|
message = "Font file for '$fontRole' not found: $fontFile"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,4 +485,288 @@ class ChordProParserTest {
|
|||||||
line.segments[2].chord shouldBe "G"
|
line.segments[2].chord shouldBe "G"
|
||||||
line.segments[2].text shouldBe "End"
|
line.segments[2].text shouldBe "End"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with multiple paragraphs`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
First paragraph of the notes.
|
||||||
|
It continues on the next line.
|
||||||
|
|
||||||
|
Second paragraph with different content.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 2
|
||||||
|
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
|
||||||
|
song.notes[1] shouldBe "Second paragraph with different content."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with single paragraph`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
A single note paragraph.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 1
|
||||||
|
song.notes[0] shouldBe "A single note paragraph."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with short directives son eon`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{son}
|
||||||
|
Short form notes.
|
||||||
|
{eon}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 1
|
||||||
|
song.notes[0] shouldBe "Short form notes."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notes block and single note directives combine`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{note: Single line note}
|
||||||
|
{start_of_notes}
|
||||||
|
Block note paragraph.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 2
|
||||||
|
song.notes[0] shouldBe "Single line note"
|
||||||
|
song.notes[1] shouldBe "Block note paragraph."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse notes block with three paragraphs`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_notes}
|
||||||
|
Paragraph one.
|
||||||
|
|
||||||
|
Paragraph two.
|
||||||
|
|
||||||
|
Paragraph three.
|
||||||
|
{end_of_notes}
|
||||||
|
{start_of_verse}
|
||||||
|
text
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.notes shouldHaveSize 3
|
||||||
|
song.notes[0] shouldBe "Paragraph one."
|
||||||
|
song.notes[1] shouldBe "Paragraph two."
|
||||||
|
song.notes[2] shouldBe "Paragraph three."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse image directive within song section`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_verse}
|
||||||
|
[Am]Hello world
|
||||||
|
{image: images/drawing.png}
|
||||||
|
[C]Goodbye world
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections shouldHaveSize 1
|
||||||
|
song.sections[0].lines shouldHaveSize 3
|
||||||
|
song.sections[0].lines[0].segments[0].chord shouldBe "Am"
|
||||||
|
song.sections[0].lines[1].imagePath shouldBe "images/drawing.png"
|
||||||
|
song.sections[0].lines[1].segments.shouldBeEmpty()
|
||||||
|
song.sections[0].lines[2].segments[0].chord shouldBe "C"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse image directive outside section creates implicit verse`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{image: images/landscape.jpg}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections shouldHaveSize 1
|
||||||
|
song.sections[0].type shouldBe SectionType.VERSE
|
||||||
|
song.sections[0].lines shouldHaveSize 1
|
||||||
|
song.sections[0].lines[0].imagePath shouldBe "images/landscape.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse multiple image directives`() {
|
||||||
|
val input = """
|
||||||
|
{title: Song}
|
||||||
|
{start_of_verse}
|
||||||
|
{image: img1.png}
|
||||||
|
Some text
|
||||||
|
{image: img2.png}
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
val song = ChordProParser.parse(input)
|
||||||
|
song.sections[0].lines shouldHaveSize 3
|
||||||
|
song.sections[0].lines[0].imagePath shouldBe "img1.png"
|
||||||
|
song.sections[0].lines[0].segments.shouldBeEmpty()
|
||||||
|
song.sections[0].lines[1].imagePath.shouldBeNull()
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.pfadfinder.songbook.parser
|
package de.pfadfinder.songbook.parser
|
||||||
|
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
|
||||||
@@ -167,6 +169,76 @@ class ConfigParserTest {
|
|||||||
config.layout.verseSpacing shouldBe 6f
|
config.layout.verseSpacing shouldBe 6f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with foreword section`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
foreword:
|
||||||
|
file: "./vorwort.txt"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.foreword.shouldNotBeNull()
|
||||||
|
config.foreword!!.file shouldBe "./vorwort.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config without foreword section has null foreword`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.foreword.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with toc highlight column`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
toc:
|
||||||
|
highlight_column: "CL"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.toc.highlightColumn shouldBe "CL"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config without toc section uses defaults`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.toc.highlightColumn.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with german metadata labels`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
layout:
|
||||||
|
metadata_labels: german
|
||||||
|
metadata_position: bottom
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.layout.metadataLabels shouldBe "german"
|
||||||
|
config.layout.metadataPosition shouldBe "bottom"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with default metadata settings`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.layout.metadataLabels shouldBe "abbreviated"
|
||||||
|
config.layout.metadataPosition shouldBe "top"
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parse config ignores unknown properties`() {
|
fun `parse config ignores unknown properties`() {
|
||||||
val yaml = """
|
val yaml = """
|
||||||
@@ -179,4 +251,21 @@ class ConfigParserTest {
|
|||||||
val config = ConfigParser.parse(yaml)
|
val config = ConfigParser.parse(yaml)
|
||||||
config.book.title shouldBe "Test"
|
config.book.title shouldBe "Test"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse config with custom title font file only`() {
|
||||||
|
val yaml = """
|
||||||
|
book:
|
||||||
|
title: "Fraktur Test"
|
||||||
|
fonts:
|
||||||
|
title: { file: "./fonts/Fraktur.ttf", size: 16 }
|
||||||
|
""".trimIndent()
|
||||||
|
val config = ConfigParser.parse(yaml)
|
||||||
|
config.fonts.title.file shouldBe "./fonts/Fraktur.ttf"
|
||||||
|
config.fonts.title.size shouldBe 16f
|
||||||
|
config.fonts.title.family shouldBe "Helvetica" // default family as fallback
|
||||||
|
// Other fonts should still use defaults
|
||||||
|
config.fonts.lyrics.file.shouldBeNull()
|
||||||
|
config.fonts.lyrics.family shouldBe "Helvetica"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.pfadfinder.songbook.parser
|
||||||
|
|
||||||
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class ForewordParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword with quote, paragraphs and signatures`() {
|
||||||
|
val input = """
|
||||||
|
> This is a quote line one
|
||||||
|
> and quote line two
|
||||||
|
---
|
||||||
|
This is the first paragraph of the foreword body.
|
||||||
|
It continues on the next line.
|
||||||
|
|
||||||
|
This is the second paragraph.
|
||||||
|
|
||||||
|
-- Max Mustermann
|
||||||
|
-- Erika Mustermann
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.quote.shouldNotBeNull()
|
||||||
|
foreword.quote shouldBe "This is a quote line one and quote line two"
|
||||||
|
foreword.paragraphs shouldHaveSize 2
|
||||||
|
foreword.paragraphs[0] shouldBe "This is the first paragraph of the foreword body. It continues on the next line."
|
||||||
|
foreword.paragraphs[1] shouldBe "This is the second paragraph."
|
||||||
|
foreword.signatures shouldHaveSize 2
|
||||||
|
foreword.signatures[0] shouldBe "Max Mustermann"
|
||||||
|
foreword.signatures[1] shouldBe "Erika Mustermann"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword without quote`() {
|
||||||
|
val input = """
|
||||||
|
This is just a paragraph.
|
||||||
|
|
||||||
|
And another one.
|
||||||
|
|
||||||
|
-- Author Name
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.quote.shouldBeNull()
|
||||||
|
foreword.paragraphs shouldHaveSize 2
|
||||||
|
foreword.paragraphs[0] shouldBe "This is just a paragraph."
|
||||||
|
foreword.paragraphs[1] shouldBe "And another one."
|
||||||
|
foreword.signatures shouldHaveSize 1
|
||||||
|
foreword.signatures[0] shouldBe "Author Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword without signatures`() {
|
||||||
|
val input = """
|
||||||
|
> A beautiful quote
|
||||||
|
---
|
||||||
|
The foreword body text goes here.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.quote shouldBe "A beautiful quote"
|
||||||
|
foreword.paragraphs shouldHaveSize 1
|
||||||
|
foreword.paragraphs[0] shouldBe "The foreword body text goes here."
|
||||||
|
foreword.signatures.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse empty foreword`() {
|
||||||
|
val foreword = ForewordParser.parse("")
|
||||||
|
|
||||||
|
foreword.quote.shouldBeNull()
|
||||||
|
foreword.paragraphs.shouldBeEmpty()
|
||||||
|
foreword.signatures.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword with only paragraphs`() {
|
||||||
|
val input = """
|
||||||
|
First paragraph.
|
||||||
|
|
||||||
|
Second paragraph.
|
||||||
|
|
||||||
|
Third paragraph.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.quote.shouldBeNull()
|
||||||
|
foreword.paragraphs shouldHaveSize 3
|
||||||
|
foreword.paragraphs[0] shouldBe "First paragraph."
|
||||||
|
foreword.paragraphs[1] shouldBe "Second paragraph."
|
||||||
|
foreword.paragraphs[2] shouldBe "Third paragraph."
|
||||||
|
foreword.signatures.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword with multi-line paragraph`() {
|
||||||
|
val input = """
|
||||||
|
This is a long paragraph that
|
||||||
|
spans multiple lines and should
|
||||||
|
be joined into a single paragraph.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.paragraphs shouldHaveSize 1
|
||||||
|
foreword.paragraphs[0] shouldBe "This is a long paragraph that spans multiple lines and should be joined into a single paragraph."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword with only a quote`() {
|
||||||
|
val input = """
|
||||||
|
> Just a quote
|
||||||
|
---
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.quote shouldBe "Just a quote"
|
||||||
|
foreword.paragraphs.shouldBeEmpty()
|
||||||
|
foreword.signatures.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse foreword with multiple signatures`() {
|
||||||
|
val input = """
|
||||||
|
Some text here.
|
||||||
|
|
||||||
|
-- Person One
|
||||||
|
-- Person Two
|
||||||
|
-- Person Three
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val foreword = ForewordParser.parse(input)
|
||||||
|
|
||||||
|
foreword.paragraphs shouldHaveSize 1
|
||||||
|
foreword.signatures shouldHaveSize 3
|
||||||
|
foreword.signatures[0] shouldBe "Person One"
|
||||||
|
foreword.signatures[1] shouldBe "Person Two"
|
||||||
|
foreword.signatures[2] shouldBe "Person Three"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,4 +206,53 @@ class ValidatorTest {
|
|||||||
errors shouldHaveSize 1
|
errors shouldHaveSize 1
|
||||||
errors[0].file shouldContain "myfile.chopro"
|
errors[0].file shouldContain "myfile.chopro"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing font file produces validation error`() {
|
||||||
|
val config = BookConfig(
|
||||||
|
fonts = FontsConfig(
|
||||||
|
title = FontSpec(file = "/nonexistent/path/FrakturFont.ttf", size = 14f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val errors = Validator.validateConfig(config)
|
||||||
|
errors shouldHaveSize 1
|
||||||
|
errors[0].message shouldContain "title"
|
||||||
|
errors[0].message shouldContain "not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple missing font files produce multiple errors`() {
|
||||||
|
val config = BookConfig(
|
||||||
|
fonts = FontsConfig(
|
||||||
|
title = FontSpec(file = "/nonexistent/title.ttf", size = 14f),
|
||||||
|
lyrics = FontSpec(file = "/nonexistent/lyrics.ttf", size = 10f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val errors = Validator.validateConfig(config)
|
||||||
|
errors shouldHaveSize 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `config with no font files produces no font errors`() {
|
||||||
|
val config = BookConfig() // all default built-in fonts
|
||||||
|
val errors = Validator.validateConfig(config)
|
||||||
|
errors.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `config with existing font file produces no error`() {
|
||||||
|
// Create a temporary file to simulate an existing font file
|
||||||
|
val tempFile = kotlin.io.path.createTempFile(suffix = ".ttf").toFile()
|
||||||
|
try {
|
||||||
|
val config = BookConfig(
|
||||||
|
fonts = FontsConfig(
|
||||||
|
title = FontSpec(file = tempFile.absolutePath, size = 14f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val errors = Validator.validateConfig(config)
|
||||||
|
errors.shouldBeEmpty()
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,24 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
val writer = PdfWriter.getInstance(document, output)
|
val writer = PdfWriter.getInstance(document, output)
|
||||||
document.open()
|
document.open()
|
||||||
|
|
||||||
// Render TOC first
|
// Render intro page (title page) if configured
|
||||||
|
if (layout.introPages > 0) {
|
||||||
|
val cb = writer.directContent
|
||||||
|
renderIntroPage(cb, fontMetrics, config, pageSize)
|
||||||
|
// Blank back of intro page (for double-sided printing)
|
||||||
|
document.newPage()
|
||||||
|
writer.directContent.let { c -> c.beginText(); c.endText() }
|
||||||
|
document.newPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render TOC
|
||||||
if (layout.tocEntries.isNotEmpty()) {
|
if (layout.tocEntries.isNotEmpty()) {
|
||||||
tocRenderer.render(document, writer, layout.tocEntries)
|
tocRenderer.render(document, writer, layout.tocEntries)
|
||||||
// Add blank pages to fill TOC allocation
|
// Pad with blank pages to fill the allocated TOC page count.
|
||||||
repeat(layout.tocPages - 1) {
|
// The table auto-paginates, so we only add the difference.
|
||||||
|
val tocPagesUsed = writer.pageNumber - layout.introPages
|
||||||
|
val paddingNeeded = maxOf(0, layout.tocPages - tocPagesUsed)
|
||||||
|
repeat(paddingNeeded) {
|
||||||
document.newPage()
|
document.newPage()
|
||||||
// Force new page even if empty
|
// Force new page even if empty
|
||||||
writer.directContent.let { cb ->
|
writer.directContent.let { cb ->
|
||||||
@@ -42,7 +55,7 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render content pages
|
// Render content pages
|
||||||
var currentPageNum = layout.tocPages + 1
|
var currentPageNum = layout.introPages + layout.tocPages + 1
|
||||||
for (pageContent in layout.pages) {
|
for (pageContent in layout.pages) {
|
||||||
// Swap margins for left/right pages
|
// Swap margins for left/right pages
|
||||||
val isRightPage = currentPageNum % 2 == 1
|
val isRightPage = currentPageNum % 2 == 1
|
||||||
@@ -63,7 +76,7 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
renderSongPage(
|
renderSongPage(
|
||||||
cb, chordLyricRenderer, fontMetrics, config,
|
cb, chordLyricRenderer, fontMetrics, config,
|
||||||
pageContent.song, pageContent.pageIndex,
|
pageContent.song, pageContent.pageIndex,
|
||||||
contentTop, leftMargin, contentWidth
|
contentTop, leftMargin, contentWidth, marginBottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is PageContent.FillerImage -> {
|
is PageContent.FillerImage -> {
|
||||||
@@ -74,6 +87,14 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.endText()
|
cb.endText()
|
||||||
}
|
}
|
||||||
|
is PageContent.ForewordPage -> {
|
||||||
|
val leftMargin = if (isRightPage) marginInner else marginOuter
|
||||||
|
renderForewordPage(
|
||||||
|
cb, fontMetrics, config,
|
||||||
|
pageContent.foreword, pageContent.pageIndex,
|
||||||
|
contentTop, leftMargin, contentWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
|
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
|
||||||
@@ -83,6 +104,165 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
document.close()
|
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(
|
private fun renderSongPage(
|
||||||
cb: PdfContentByte,
|
cb: PdfContentByte,
|
||||||
chordLyricRenderer: ChordLyricRenderer,
|
chordLyricRenderer: ChordLyricRenderer,
|
||||||
@@ -92,10 +272,28 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
|
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
|
||||||
contentTop: Float,
|
contentTop: Float,
|
||||||
leftMargin: Float,
|
leftMargin: Float,
|
||||||
contentWidth: Float
|
contentWidth: Float,
|
||||||
|
bottomMargin: Float
|
||||||
) {
|
) {
|
||||||
var y = contentTop
|
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) {
|
if (pageIndex == 0) {
|
||||||
// Render title
|
// Render title
|
||||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||||
@@ -108,20 +306,23 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
cb.endText()
|
cb.endText()
|
||||||
y -= titleSize * 1.5f
|
y -= titleSize * 1.5f
|
||||||
|
|
||||||
// Render metadata line (composer/lyricist)
|
// Render metadata line (composer/lyricist) - at top position only
|
||||||
val metaParts = mutableListOf<String>()
|
if (!renderMetaAtBottom) {
|
||||||
song.composer?.let { metaParts.add("M: $it") }
|
val metaParts = buildMetadataLines(song, config)
|
||||||
song.lyricist?.let { metaParts.add("T: $it") }
|
if (metaParts.isNotEmpty()) {
|
||||||
if (metaParts.isNotEmpty()) {
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaSize = config.fonts.metadata.size
|
||||||
val metaSize = config.fonts.metadata.size
|
for (metaLine in metaParts) {
|
||||||
cb.beginText()
|
if (y - metaSize * 1.8f < yMin) break
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.beginText()
|
||||||
cb.setColorFill(Color.GRAY)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setColorFill(Color.GRAY)
|
||||||
cb.showText(metaParts.joinToString(" / "))
|
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||||
cb.endText()
|
cb.showText(metaLine)
|
||||||
y -= metaSize * 1.8f
|
cb.endText()
|
||||||
|
y -= metaSize * 1.8f
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render key and capo
|
// Render key and capo
|
||||||
@@ -131,24 +332,31 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
if (infoParts.isNotEmpty()) {
|
if (infoParts.isNotEmpty()) {
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
cb.beginText()
|
if (y - metaSize * 1.8f >= yMin) {
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.beginText()
|
||||||
cb.setColorFill(Color.GRAY)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setColorFill(Color.GRAY)
|
||||||
cb.showText(infoParts.joinToString(" | "))
|
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||||
cb.endText()
|
cb.showText(infoParts.joinToString(" | "))
|
||||||
y -= metaSize * 1.8f
|
cb.endText()
|
||||||
|
y -= metaSize * 1.8f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
y -= 4f // gap before sections
|
y -= 4f // gap before sections
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which sections to render on this page
|
// Determine which sections to render on this page
|
||||||
// For simplicity in this implementation, render all sections on pageIndex 0
|
val sections = if (pageIndex == 0) {
|
||||||
// A more sophisticated implementation would split sections across pages
|
song.sections.subList(0, splitIndex)
|
||||||
val sections = if (pageIndex == 0) song.sections else emptyList()
|
} else {
|
||||||
|
song.sections.subList(splitIndex, song.sections.size)
|
||||||
|
}
|
||||||
|
|
||||||
for (section in sections) {
|
for (section in sections) {
|
||||||
|
// Safety check: stop rendering if we've gone below the boundary
|
||||||
|
if (y < yMin) break
|
||||||
|
|
||||||
// Section label
|
// Section label
|
||||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
if (section.label != null || section.type == SectionType.CHORUS) {
|
||||||
val labelText = section.label ?: when (section.type) {
|
val labelText = section.label ?: when (section.type) {
|
||||||
@@ -159,6 +367,7 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
if (labelText != null) {
|
if (labelText != null) {
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
|
if (y - metaSize * 1.5f < yMin) break
|
||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
@@ -173,6 +382,7 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
|
if (y - metaSize * 1.8f < yMin) break
|
||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
@@ -197,44 +407,399 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
|
|
||||||
// Render lines
|
// Render lines
|
||||||
for (line in section.lines) {
|
for (line in section.lines) {
|
||||||
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
if (y < yMin) break
|
||||||
y -= height + 1f // 1pt gap between lines
|
val imgPath = line.imagePath
|
||||||
|
if (imgPath != null) {
|
||||||
|
// Render inline image
|
||||||
|
y -= renderInlineImage(cb, imgPath, leftMargin, y, contentWidth)
|
||||||
|
} else {
|
||||||
|
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
||||||
|
y -= height + 1f // 1pt gap between lines
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// End repeat marker
|
// End repeat marker
|
||||||
if (section.type == SectionType.REPEAT) {
|
if (section.type == SectionType.REPEAT) {
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
cb.beginText()
|
if (y - metaSize * 1.5f >= yMin) {
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.beginText()
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
cb.showText(":\u2502")
|
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||||
cb.endText()
|
cb.showText(":\u2502")
|
||||||
y -= metaSize * 1.5f
|
cb.endText()
|
||||||
|
y -= metaSize * 1.5f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verse spacing
|
// Verse spacing
|
||||||
y -= config.layout.verseSpacing / 0.3528f
|
y -= config.layout.verseSpacing / 0.3528f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render notes at the bottom
|
// Render footer elements (notes, metadata, references) anchored to the bottom of the page.
|
||||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
// Instead of flowing from the current y position after song content, we compute a fixed
|
||||||
y -= 4f
|
// 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 metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
val metaSize = config.fonts.metadata.size
|
val metaSize = config.fonts.metadata.size
|
||||||
for (note in song.notes) {
|
|
||||||
|
// The footer area spans from bottomMargin to bottomMargin + footerReservation.
|
||||||
|
// Start rendering from the top of this area, flowing downward.
|
||||||
|
var footerY = bottomMargin + footerReservation
|
||||||
|
|
||||||
|
// Render notes (topmost footer element)
|
||||||
|
if (song.notes.isNotEmpty()) {
|
||||||
|
footerY -= 4f // gap before notes
|
||||||
|
val noteLineHeight = metaSize * 1.5f
|
||||||
|
for ((idx, note) in song.notes.withIndex()) {
|
||||||
|
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
|
||||||
|
for (wrappedLine in wrappedLines) {
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.GRAY)
|
||||||
|
cb.setTextMatrix(leftMargin, footerY - metaSize)
|
||||||
|
cb.showText(wrappedLine)
|
||||||
|
cb.endText()
|
||||||
|
footerY -= noteLineHeight
|
||||||
|
}
|
||||||
|
if (idx < song.notes.size - 1) {
|
||||||
|
footerY -= noteLineHeight * 0.3f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, footerY, contentWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build metadata lines based on configured label style.
|
||||||
|
* Returns a list of lines to render (may be empty).
|
||||||
|
*/
|
||||||
|
private fun buildMetadataLines(song: Song, config: BookConfig): List<String> {
|
||||||
|
val useGerman = config.layout.metadataLabels == "german"
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (useGerman) {
|
||||||
|
// German labels: "Worte und Weise:" when same person, otherwise separate
|
||||||
|
if (song.lyricist != null && song.composer != null && song.lyricist == song.composer) {
|
||||||
|
lines.add("Worte und Weise: ${song.lyricist}")
|
||||||
|
} else {
|
||||||
|
song.lyricist?.let { lines.add("Worte: $it") }
|
||||||
|
song.composer?.let { lines.add("Weise: $it") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Abbreviated labels on a single line
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
song.composer?.let { parts.add("M: $it") }
|
||||||
|
song.lyricist?.let { parts.add("T: $it") }
|
||||||
|
if (parts.isNotEmpty()) {
|
||||||
|
lines.add(parts.joinToString(" / "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
fontMetrics: PdfFontMetrics,
|
||||||
|
config: BookConfig,
|
||||||
|
song: Song,
|
||||||
|
leftMargin: Float,
|
||||||
|
topY: Float,
|
||||||
|
contentWidth: Float
|
||||||
|
) {
|
||||||
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
|
val metaSize = config.fonts.metadata.size
|
||||||
|
val lineHeight = metaSize * 1.4f
|
||||||
|
|
||||||
|
val books = config.referenceBooks
|
||||||
|
|
||||||
|
// Calculate column widths: evenly distribute across content width
|
||||||
|
val colWidth = contentWidth / books.size
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
val textX = x + (colWidth - textWidth) / 2
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(textX, abbrY)
|
||||||
|
cb.showText(abbr)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
if (pageNum != null) {
|
||||||
|
val pageText = pageNum.toString()
|
||||||
|
val textWidth = metaFont.getWidthPoint(pageText, metaSize)
|
||||||
|
val textX = x + (colWidth - textWidth) / 2
|
||||||
|
|
||||||
cb.beginText()
|
cb.beginText()
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
cb.setColorFill(Color.GRAY)
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
cb.setTextMatrix(textX, numY)
|
||||||
cb.showText(note)
|
cb.showText(pageText)
|
||||||
cb.endText()
|
cb.endText()
|
||||||
y -= metaSize * 1.5f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun renderForewordPage(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
fontMetrics: PdfFontMetrics,
|
||||||
|
config: BookConfig,
|
||||||
|
foreword: Foreword,
|
||||||
|
pageIndex: Int,
|
||||||
|
contentTop: Float,
|
||||||
|
leftMargin: Float,
|
||||||
|
contentWidth: Float
|
||||||
|
) {
|
||||||
|
var y = contentTop
|
||||||
|
val bodyFontSpec = config.fonts.lyrics
|
||||||
|
val bodyFont = fontMetrics.getBaseFont(bodyFontSpec)
|
||||||
|
val bodySize = bodyFontSpec.size
|
||||||
|
val lineHeight = bodySize * 1.5f
|
||||||
|
|
||||||
|
if (pageIndex == 0) {
|
||||||
|
// Page 1: Quote + separator + first paragraphs
|
||||||
|
|
||||||
|
// Render quote in italic (bold)
|
||||||
|
val quoteText = foreword.quote
|
||||||
|
if (quoteText != null) {
|
||||||
|
val quoteFont = fontMetrics.getBaseFontBold(bodyFontSpec)
|
||||||
|
val quoteSize = bodySize * 1.1f
|
||||||
|
|
||||||
|
// Word-wrap the quote text
|
||||||
|
val quoteLines = wrapText(quoteText, quoteFont, quoteSize, contentWidth)
|
||||||
|
for (quoteLine in quoteLines) {
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(quoteFont, quoteSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(leftMargin, y - quoteSize)
|
||||||
|
cb.showText(quoteLine)
|
||||||
|
cb.endText()
|
||||||
|
y -= quoteSize * 1.5f
|
||||||
|
}
|
||||||
|
|
||||||
|
y -= 6f // gap before separator
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
cb.setLineWidth(0.5f)
|
||||||
|
cb.setColorStroke(Color.GRAY)
|
||||||
|
cb.moveTo(leftMargin, y)
|
||||||
|
cb.lineTo(leftMargin + contentWidth, y)
|
||||||
|
cb.stroke()
|
||||||
|
y -= 10f // gap after separator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render body paragraphs
|
||||||
|
for (paragraph in foreword.paragraphs) {
|
||||||
|
val wrappedLines = wrapText(paragraph, bodyFont, bodySize, contentWidth)
|
||||||
|
for (wrappedLine in wrappedLines) {
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(bodyFont, bodySize)
|
||||||
|
cb.setColorFill(Color.BLACK)
|
||||||
|
cb.setTextMatrix(leftMargin, y - bodySize)
|
||||||
|
cb.showText(wrappedLine)
|
||||||
|
cb.endText()
|
||||||
|
y -= lineHeight
|
||||||
|
}
|
||||||
|
y -= lineHeight * 0.5f // paragraph spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render signatures (right-aligned)
|
||||||
|
if (foreword.signatures.isNotEmpty()) {
|
||||||
|
y -= lineHeight // extra gap before signatures
|
||||||
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
|
val metaSize = config.fonts.metadata.size
|
||||||
|
for (signature in foreword.signatures) {
|
||||||
|
val sigWidth = metaFont.getWidthPoint(signature, metaSize)
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(leftMargin + contentWidth - sigWidth, y - metaSize)
|
||||||
|
cb.showText(signature)
|
||||||
|
cb.endText()
|
||||||
|
y -= metaSize * 1.8f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Page 2: blank (or overflow content if needed in the future)
|
||||||
|
cb.beginText()
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple word-wrap: splits text into lines that fit within maxWidth (in points).
|
||||||
|
*/
|
||||||
|
private fun wrapText(text: String, font: BaseFont, fontSize: Float, maxWidth: Float): List<String> {
|
||||||
|
val words = text.split(" ")
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
var currentLine = StringBuilder()
|
||||||
|
|
||||||
|
for (word in words) {
|
||||||
|
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
|
||||||
|
val testWidth = font.getWidthPoint(testLine, fontSize)
|
||||||
|
if (testWidth <= maxWidth) {
|
||||||
|
currentLine = StringBuilder(testLine)
|
||||||
|
} else {
|
||||||
|
if (currentLine.isNotEmpty()) {
|
||||||
|
lines.add(currentLine.toString())
|
||||||
|
}
|
||||||
|
currentLine = StringBuilder(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine.isNotEmpty()) {
|
||||||
|
lines.add(currentLine.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an inline image within a song page at the given position.
|
||||||
|
* Returns the total height consumed in PDF points.
|
||||||
|
*/
|
||||||
|
private fun renderInlineImage(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
imagePath: String,
|
||||||
|
leftMargin: Float,
|
||||||
|
y: Float,
|
||||||
|
contentWidth: Float
|
||||||
|
): Float {
|
||||||
|
try {
|
||||||
|
val img = Image.getInstance(imagePath)
|
||||||
|
// Scale to fit within content width, max height 40mm (~113 points)
|
||||||
|
val maxHeight = 40f / 0.3528f // 40mm in points
|
||||||
|
img.scaleToFit(contentWidth * 0.8f, maxHeight)
|
||||||
|
|
||||||
|
// Center horizontally
|
||||||
|
val imgX = leftMargin + (contentWidth - img.scaledWidth) / 2
|
||||||
|
val imgY = y - img.scaledHeight - 3f // 3pt gap above
|
||||||
|
|
||||||
|
img.setAbsolutePosition(imgX, imgY)
|
||||||
|
cb.addImage(img)
|
||||||
|
|
||||||
|
return img.scaledHeight + 6f // image height + gaps above/below
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// If image can't be loaded, consume minimal space
|
||||||
|
return 5f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderIntroPage(
|
||||||
|
cb: PdfContentByte,
|
||||||
|
fontMetrics: PdfFontMetrics,
|
||||||
|
config: BookConfig,
|
||||||
|
pageSize: Rectangle
|
||||||
|
) {
|
||||||
|
val pageWidth = pageSize.width
|
||||||
|
val pageHeight = pageSize.height
|
||||||
|
|
||||||
|
// Title centered on the page
|
||||||
|
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||||
|
val titleSize = config.fonts.title.size * 2.5f
|
||||||
|
val title = config.book.title
|
||||||
|
val titleWidth = titleFont.getWidthPoint(title, titleSize)
|
||||||
|
val titleX = (pageWidth - titleWidth) / 2
|
||||||
|
val titleY = pageHeight * 0.55f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(titleFont, titleSize)
|
||||||
|
cb.setColorFill(Color.BLACK)
|
||||||
|
cb.setTextMatrix(titleX, titleY)
|
||||||
|
cb.showText(title)
|
||||||
|
cb.endText()
|
||||||
|
|
||||||
|
// Subtitle below the title
|
||||||
|
if (config.book.subtitle != null) {
|
||||||
|
val subtitleSize = config.fonts.title.size * 1.2f
|
||||||
|
val subtitle = config.book.subtitle!!
|
||||||
|
val subtitleWidth = titleFont.getWidthPoint(subtitle, subtitleSize)
|
||||||
|
val subtitleX = (pageWidth - subtitleWidth) / 2
|
||||||
|
val subtitleY = titleY - titleSize * 1.8f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(titleFont, subtitleSize)
|
||||||
|
cb.setColorFill(Color.DARK_GRAY)
|
||||||
|
cb.setTextMatrix(subtitleX, subtitleY)
|
||||||
|
cb.showText(subtitle)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edition at the bottom of the page
|
||||||
|
if (config.book.edition != null) {
|
||||||
|
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||||
|
val metaSize = config.fonts.metadata.size * 1.2f
|
||||||
|
val edition = config.book.edition!!
|
||||||
|
val editionWidth = metaFont.getWidthPoint(edition, metaSize)
|
||||||
|
val editionX = (pageWidth - editionWidth) / 2
|
||||||
|
val editionY = pageHeight * 0.1f
|
||||||
|
|
||||||
|
cb.beginText()
|
||||||
|
cb.setFontAndSize(metaFont, metaSize)
|
||||||
|
cb.setColorFill(Color.GRAY)
|
||||||
|
cb.setTextMatrix(editionX, editionY)
|
||||||
|
cb.showText(edition)
|
||||||
|
cb.endText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
||||||
try {
|
try {
|
||||||
val img = Image.getInstance(imagePath)
|
val img = Image.getInstance(imagePath)
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ package de.pfadfinder.songbook.renderer.pdf
|
|||||||
import com.lowagie.text.pdf.BaseFont
|
import com.lowagie.text.pdf.BaseFont
|
||||||
import de.pfadfinder.songbook.model.FontMetrics
|
import de.pfadfinder.songbook.model.FontMetrics
|
||||||
import de.pfadfinder.songbook.model.FontSpec
|
import de.pfadfinder.songbook.model.FontSpec
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class PdfFontMetrics : FontMetrics {
|
class PdfFontMetrics : FontMetrics {
|
||||||
private val fontCache = mutableMapOf<String, BaseFont>()
|
private val fontCache = mutableMapOf<String, BaseFont>()
|
||||||
|
|
||||||
fun getBaseFont(font: FontSpec): BaseFont {
|
fun getBaseFont(font: FontSpec): BaseFont {
|
||||||
val key = font.file ?: font.family
|
val fontFile = font.file
|
||||||
|
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
|
||||||
return fontCache.getOrPut(key) {
|
return fontCache.getOrPut(key) {
|
||||||
if (font.file != null) {
|
if (fontFile != null) {
|
||||||
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
val file = File(fontFile)
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw IllegalArgumentException("Font file not found: ${file.absolutePath}")
|
||||||
|
}
|
||||||
|
BaseFont.createFont(file.absolutePath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
||||||
} else {
|
} else {
|
||||||
// Map common family names to built-in PDF fonts
|
// Map common family names to built-in PDF fonts
|
||||||
val pdfFontName = when (font.family.lowercase()) {
|
val pdfFontName = when (font.family.lowercase()) {
|
||||||
|
|||||||
@@ -3,11 +3,41 @@ package de.pfadfinder.songbook.renderer.pdf
|
|||||||
import com.lowagie.text.*
|
import com.lowagie.text.*
|
||||||
import com.lowagie.text.pdf.*
|
import com.lowagie.text.pdf.*
|
||||||
import de.pfadfinder.songbook.model.*
|
import de.pfadfinder.songbook.model.*
|
||||||
|
import java.awt.Color
|
||||||
|
|
||||||
class TocRenderer(
|
class TocRenderer(
|
||||||
private val fontMetrics: PdfFontMetrics,
|
private val fontMetrics: PdfFontMetrics,
|
||||||
private val config: BookConfig
|
private val config: BookConfig
|
||||||
) {
|
) {
|
||||||
|
// Light gray background for the highlighted column
|
||||||
|
private val highlightColor = Color(220, 220, 220)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-renders the TOC to a temporary document and returns the number of pages needed,
|
||||||
|
* rounded up to an even number for double-sided printing.
|
||||||
|
*/
|
||||||
|
fun measurePages(tocEntries: List<TocEntry>): Int {
|
||||||
|
val pageSize = if (config.book.format == "A5") PageSize.A5 else PageSize.A4
|
||||||
|
val marginInner = config.layout.margins.inner / 0.3528f
|
||||||
|
val marginOuter = config.layout.margins.outer / 0.3528f
|
||||||
|
val marginTop = config.layout.margins.top / 0.3528f
|
||||||
|
val marginBottom = config.layout.margins.bottom / 0.3528f
|
||||||
|
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
val doc = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
|
||||||
|
val writer = PdfWriter.getInstance(doc, baos)
|
||||||
|
doc.open()
|
||||||
|
render(doc, writer, tocEntries)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
val reader = PdfReader(baos.toByteArray())
|
||||||
|
val pageCount = reader.numberOfPages
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
// Round to even for double-sided printing
|
||||||
|
return if (pageCount % 2 == 0) pageCount else pageCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
||||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
||||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
||||||
@@ -26,21 +56,33 @@ class TocRenderer(
|
|||||||
val table = PdfPTable(numCols)
|
val table = PdfPTable(numCols)
|
||||||
table.widthPercentage = 100f
|
table.widthPercentage = 100f
|
||||||
|
|
||||||
// Set column widths: title takes most space
|
// Set column widths: title takes most space, ref columns need room for 3-digit numbers
|
||||||
val widths = FloatArray(numCols)
|
val widths = FloatArray(numCols)
|
||||||
widths[0] = 10f // title
|
widths[0] = 12f // title
|
||||||
widths[1] = 1.5f // page
|
widths[1] = 1.5f // page
|
||||||
for (i in refBooks.indices) {
|
for (i in refBooks.indices) {
|
||||||
widths[2 + i] = 1.5f
|
widths[2 + i] = 1.5f // enough for 3-digit page numbers; headers are rotated 90°
|
||||||
}
|
}
|
||||||
table.setWidths(widths)
|
table.setWidths(widths)
|
||||||
|
|
||||||
// Header row
|
// Determine which column index should be highlighted
|
||||||
|
val highlightAbbrev = config.toc.highlightColumn
|
||||||
|
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
|
||||||
|
if (highlightAbbrev == "Seite") {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
val refIndex = refBooks.indexOfFirst { it.abbreviation == highlightAbbrev }
|
||||||
|
if (refIndex >= 0) 2 + refIndex else null
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Header row — reference book columns are rotated 90°
|
||||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||||
table.addCell(headerCell("Titel", headerFont))
|
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
||||||
table.addCell(headerCell("Seite", headerFont))
|
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
||||||
for (book in refBooks) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
|
table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted))
|
||||||
}
|
}
|
||||||
table.headerRows = 1
|
table.headerRows = 1
|
||||||
|
|
||||||
@@ -49,31 +91,64 @@ class TocRenderer(
|
|||||||
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
||||||
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
||||||
val font = if (entry.isAlias) aliasFont else entryFont
|
val font = if (entry.isAlias) aliasFont else entryFont
|
||||||
table.addCell(entryCell(entry.title, font))
|
table.addCell(entryCell(entry.title, font, isHighlighted = false))
|
||||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1))
|
||||||
for (book in refBooks) {
|
for ((i, book) in refBooks.withIndex()) {
|
||||||
val ref = entry.references[book.abbreviation]
|
val ref = entry.references[book.abbreviation]
|
||||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
val isHighlighted = highlightColumnIndex == 2 + i
|
||||||
|
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_CENTER, isHighlighted = isHighlighted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.add(table)
|
document.add(table)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
cell.borderWidth = 0f
|
cell.borderWidth = 0f
|
||||||
cell.borderWidthBottom = 0.5f
|
cell.borderWidthBottom = 0.5f
|
||||||
cell.paddingBottom = 4f
|
cell.paddingBottom = 4f
|
||||||
|
cell.verticalAlignment = Element.ALIGN_BOTTOM
|
||||||
|
if (isHighlighted) {
|
||||||
|
cell.backgroundColor = highlightColor
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryCell(
|
||||||
|
text: String,
|
||||||
|
font: Font,
|
||||||
|
alignment: Int = Element.ALIGN_LEFT,
|
||||||
|
isHighlighted: Boolean = false
|
||||||
|
): PdfPCell {
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
cell.borderWidth = 0f
|
cell.borderWidth = 0f
|
||||||
cell.horizontalAlignment = alignment
|
cell.horizontalAlignment = alignment
|
||||||
cell.paddingTop = 1f
|
cell.paddingTop = 1f
|
||||||
cell.paddingBottom = 1f
|
cell.paddingBottom = 1f
|
||||||
|
if (isHighlighted) {
|
||||||
|
cell.backgroundColor = highlightColor
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,4 +417,213 @@ class PdfBookRendererTest {
|
|||||||
|
|
||||||
baos.size() shouldBeGreaterThan 0
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.kotest.matchers.floats.shouldBeLessThan
|
|||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
class PdfFontMetricsTest {
|
class PdfFontMetricsTest {
|
||||||
|
|
||||||
@@ -158,4 +159,73 @@ class PdfFontMetricsTest {
|
|||||||
height shouldBeGreaterThan 3f
|
height shouldBeGreaterThan 3f
|
||||||
height shouldBeLessThan 6f
|
height shouldBeLessThan 6f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Custom font file tests ---
|
||||||
|
|
||||||
|
private val testFontPath: String
|
||||||
|
get() = this::class.java.getResource("/TestFont.ttf")!!.file
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getBaseFont loads custom font from file path`() {
|
||||||
|
val font = FontSpec(file = testFontPath, size = 12f)
|
||||||
|
val baseFont = metrics.getBaseFont(font)
|
||||||
|
// Custom font should load successfully and have a non-null PostScript name
|
||||||
|
baseFont.postscriptFontName.isNotEmpty() shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getBaseFont caches custom font by canonical path`() {
|
||||||
|
val font1 = FontSpec(file = testFontPath, size = 12f)
|
||||||
|
val font2 = FontSpec(file = testFontPath, size = 14f) // different size, same file
|
||||||
|
val first = metrics.getBaseFont(font1)
|
||||||
|
val second = metrics.getBaseFont(font2)
|
||||||
|
first shouldBeSameInstanceAs second
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getBaseFont throws for missing font file`() {
|
||||||
|
val font = FontSpec(file = "/nonexistent/path/MissingFont.ttf", size = 12f)
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
metrics.getBaseFont(font)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getBaseFontBold returns same font when file is specified`() {
|
||||||
|
val font = FontSpec(file = testFontPath, size = 12f)
|
||||||
|
val regular = metrics.getBaseFont(font)
|
||||||
|
val bold = metrics.getBaseFontBold(font)
|
||||||
|
// Custom fonts don't have auto-resolved bold variants
|
||||||
|
regular shouldBeSameInstanceAs bold
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `measureTextWidth works with custom font file`() {
|
||||||
|
val font = FontSpec(file = testFontPath, size = 12f)
|
||||||
|
val width = metrics.measureTextWidth("Hello World", font, 12f)
|
||||||
|
width shouldBeGreaterThan 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `measureTextWidth handles German umlauts with custom font`() {
|
||||||
|
val font = FontSpec(file = testFontPath, size = 12f)
|
||||||
|
// These should not throw and should return positive widths
|
||||||
|
val umlautWidth = metrics.measureTextWidth("\u00e4\u00f6\u00fc\u00df", font, 12f)
|
||||||
|
umlautWidth shouldBeGreaterThan 0f
|
||||||
|
|
||||||
|
// Full German words with umlauts
|
||||||
|
val wordWidth = metrics.measureTextWidth("Gr\u00fc\u00dfe aus \u00d6sterreich", font, 12f)
|
||||||
|
wordWidth shouldBeGreaterThan 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `measureTextWidth with custom font returns different width than built-in font`() {
|
||||||
|
val customFont = FontSpec(file = testFontPath, size = 10f)
|
||||||
|
val builtInFont = FontSpec(family = "Courier", size = 10f) // use Courier for contrast
|
||||||
|
val customWidth = metrics.measureTextWidth("Test text", customFont, 10f)
|
||||||
|
val builtInWidth = metrics.measureTextWidth("Test text", builtInFont, 10f)
|
||||||
|
// They should both be positive but likely different
|
||||||
|
customWidth shouldBeGreaterThan 0f
|
||||||
|
builtInWidth shouldBeGreaterThan 0f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
Binary file not shown.
@@ -14,11 +14,16 @@ fonts:
|
|||||||
title: { family: "Helvetica", size: 14 }
|
title: { family: "Helvetica", size: 14 }
|
||||||
metadata: { family: "Helvetica", size: 8 }
|
metadata: { family: "Helvetica", size: 8 }
|
||||||
toc: { family: "Helvetica", size: 9 }
|
toc: { family: "Helvetica", size: 9 }
|
||||||
|
# To use a custom font file (e.g. Fraktur/Blackletter for titles):
|
||||||
|
# title: { file: "./fonts/FrakturFont.ttf", size: 16 }
|
||||||
|
# The file path is relative to the project directory.
|
||||||
|
# Supported formats: .ttf, .otf
|
||||||
|
# Custom fonts are embedded in the PDF and support Unicode (including umlauts).
|
||||||
|
|
||||||
layout:
|
layout:
|
||||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||||
chord_line_spacing: 3
|
chord_line_spacing: 1
|
||||||
verse_spacing: 4
|
verse_spacing: 6
|
||||||
page_number_position: bottom-outer
|
page_number_position: bottom-outer
|
||||||
|
|
||||||
images:
|
images:
|
||||||
@@ -32,6 +37,9 @@ reference_books:
|
|||||||
name: "Pfadfinderliederbuch"
|
name: "Pfadfinderliederbuch"
|
||||||
abbreviation: "PfLB"
|
abbreviation: "PfLB"
|
||||||
|
|
||||||
|
toc:
|
||||||
|
highlight_column: "Seite"
|
||||||
|
|
||||||
output:
|
output:
|
||||||
directory: "./output"
|
directory: "./output"
|
||||||
filename: "liederbuch.pdf"
|
filename: "liederbuch.pdf"
|
||||||
|
|||||||
Reference in New Issue
Block a user