19 Commits

Author SHA1 Message Date
shahondin1624
b8a0cd7aa3 Sort TOC entries alphabetically including aliases
Add a Python sort step in the Makefile that sorts .songtoc by
the actual title text, stripping \textit{} wrappers so aliases
sort into their correct alphabetical position alongside main
titles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:28:17 +02:00
shahondin1624
2d4e1554c7 Fix TOC: restore page breaks and remove blank page
- Change \\* back to \\ in songtocrow to allow longtable page breaks
- Restore continuation header on subsequent TOC pages
- Use \cleardoublepage before TOC to eliminate blank page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:59:59 +02:00
shahondin1624
63fe1effdb Fix TOC: embed title in longtable to prevent blank first page
Move "Inhaltsverzeichnis" title into the longtable as a
multicolumn first row so it stays with the table content.
Remove duplicate header on continuation pages. Reduce header
rotation to 60 degrees with scriptsize font.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:32:47 +02:00
shahondin1624
bb2f829e2f Remove trailing hline after last TOC entry
Replace \hline with \\* in songtocrow to avoid an empty partial
row appearing after the last song in the table of contents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:24:18 +02:00
shahondin1624
07aa76c3f6 Add Goethe filler page and clean up image artifacts
- Insert Goethe poem illustration (img-000.jpg) as filler page
  between TOC and first song (matching reference layout)
- Remove 10 alpha mask/tiny artifact images

The remaining ~65 unused images are mostly on song pages where
CL number matching failed — they need manual assignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:10:36 +02:00
shahondin1624
0e8660cd41 Extract and insert 97 images from reference PDF into songs
Extract images from the CL6 PDF using pdfimages, map them to songs
via page-to-CL number matching, and insert \songimage commands.
Add insert-images.py script for repeatable extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:03:07 +02:00
shahondin1624
c202f1a792 Convert 47 song anecdotes from comments to note properties
The import script stored anecdotes/historical context as LaTeX
comments. Convert them to the note song property so they render
at the bottom of each song page (matching the reference style).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:17:28 +02:00
shahondin1624
e771264244 Add song aliases to TOC and fix page numbering
- 82 songs get alias entries (alternate titles/opening lines) shown
  in italic in the TOC, pointing to the same page as the main title
- Front matter (title, foreword, TOC) has no page numbers
- Song pages start at page 1
- Aliases extracted from reference PDF (CL6) TOC by title matching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:08:20 +02:00
shahondin1624
44ea072716 Remove paragraph indentation and Carmina Leonis from song footer
- Set \parindent to 0pt for clean left-aligned text
- Remove current book column from song page footer (keep only
  reference books: BuLiBu, CL gr., SwA, Barde, LiBock)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:23:22 +02:00
shahondin1624
21c369da36 Fix Makefile to auto-stabilize TOC with up to 5 passes
The .songtoc file needs multiple passes to stabilize: pass 1
writes song data, pass 2 reads it into the TOC (changing page
count), pass 3+ stabilizes page references. The Makefile now
loops until the log shows no more "Rerun" warnings or max 5
passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:11:30 +02:00
shahondin1624
7b99778f67 Fix chord alignment: snap to word boundaries
Improve merge_chord_lyric() to snap chord positions to the start
of the word they fall within, instead of splitting words mid-way.
Fixes artifacts like "Liebespaar \chord{C}e" → "\chord{C}Liebespaare".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:36:06 +02:00
shahondin1624
d875fd225b Rename to Carmina Leonis and add original foreword text
- Rename "Liederbuch" to "Carmina Leonis" in TOC header, song
  footer, and title page
- Replace placeholder foreword with original CL6 introductory text
- Update title page to "Carmina Leonis, 7. Auflage, 2026"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:10:02 +02:00
shahondin1624
44d2fb9b5e Update TOC and footer to use imported reference books
Replace MO/PfLB columns with the actual reference books from the
Carmina Leonis import: BuLiBu, CL (gr.), SwA, Barde, LiBock.
Add third LaTeX pass to Makefile for TOC stabilization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:55:55 +02:00
shahondin1624
93f451eef9 Import 294 songs from Carmina Leonis PDF
Add import-songs.py script that extracts songs from the PDF text
and generates .tex files with leadsheets format. Adds song
properties for all reference books (BuLiBu, BuLiBuII, CL, SwA,
Barde, LiBock). Generates all-songs.tex with alphabetical inputs.

Note: Chord alignment is approximate from PDF extraction and
may need manual review for some songs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:40:59 +02:00
shahondin1624
ab00b710b1 Fix rowcolors bleeding into chord tabulars
Reset \rowcolors after the TOC longtable to prevent alternating
gray backgrounds from affecting leadsheets' chord placement
tabulars, which was clipping/overlaying song lyrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:03:27 +02:00
shahondin1624
5a63067b93 Add foreword page and image placement commands
- Introductory page with quote, horizontal rule, and foreword text
- \fillerpage{path} for full-page centered filler images
- \songimage{path} for inline images within song pages
- \fullpageimage{path} for borderless full-page images
- Added graphicx and csquotes packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:54:21 +02:00
shahondin1624
cae0c52b67 Fix TOC page references and song page footer
- Move \label outside dedup guard so it survives the real pass
  (measurement pass labels are discarded inside vbox)
- Song page footer now shows MO/PfLB/Liederbuch table matching TOC
- Fix TEXINPUTS to include output/ for .songtoc file access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:23:04 +02:00
shahondin1624
692be693e9 Add matrix TOC with cross-reference columns
Replace the default LaTeX TOC with a table matching the Carmina
Leonis style: song titles with columns for reference books (MO,
PfLB) and the current songbook page number. Uses expl3 iow to
write song data to a .songtoc file during compilation and reads
it back on the second pass. Alternating row colors via rowcolors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:06:05 +02:00
shahondin1624
4024d0e421 Rewrite songbook as pure LaTeX project (Carmina Leonis style)
Replace the Kotlin/Gradle multi-module pipeline with a pure LaTeX
songbook using the leadsheets package and LuaLaTeX. Style matches
the Carmina Leonis (CL6) scout songbook: Fraktur titles, chords
above lyrics, metadata at page bottom, reference book footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:23:57 +02:00
541 changed files with 16286 additions and 8102 deletions

35
.gitignore vendored
View File

@@ -1,22 +1,29 @@
# Gradle # LaTeX build artifacts
.gradle/ *.aux
build/ *.log
buildSrc/build/ *.out
*.toc
*.fls
*.fdb_latexmk
*.synctex.gz
*.synctex(busy)
*.sxd
*.sxc
# IDE # Output directory
.idea/ output/
*.iml
.vscode/
# OS # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Output # Editor files
output/ .idea/
*.iml
# Kotlin .vscode/
*.class *~
*.swp
# Claude # Claude
.claude/ .claude/
__pycache__/

View File

@@ -1,109 +0,0 @@
# 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.

View File

@@ -1,44 +0,0 @@
# 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.

View File

@@ -1,41 +0,0 @@
# 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`.

138
CLAUDE.md
View File

@@ -2,105 +2,83 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands ## Build Commands
```bash ```bash
# Build everything # Build the songbook PDF (two-pass for TOC)
gradle build make
# Run all tests # Remove auxiliary files
gradle test make clean
# Run tests for a specific module # Remove everything including PDF
gradle :parser:test make distclean
gradle :layout:test
gradle :renderer-pdf:test
gradle :app:test
# Run a single test class
gradle :parser:test --tests ChordProParserTest
# Run a single test method
gradle :parser:test --tests "ChordProParserTest.parse complete song"
# Build and run CLI
gradle :cli:run --args="build -d /path/to/project"
gradle :cli:run --args="validate -d /path/to/project"
# Launch GUI
gradle :gui:run
``` ```
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1. Requires LuaLaTeX (TeX Live) and the `leadsheets` package.
## Architecture ## Project Structure
**Pipeline:** Parse → Validate → Measure → Paginate → Render
`SongbookPipeline` (in `app`) orchestrates the full flow:
1. `ConfigParser` reads `songbook.yaml``BookConfig`
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
3. `ForewordParser` reads optional `foreword.txt``Foreword` (if configured)
4. `Validator` checks config and songs
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
6. `TocGenerator` estimates TOC page count and creates entries
7. `PaginationEngine` arranges songs into pages (greedy spread packing)
8. `PdfBookRenderer` generates the PDF via OpenPDF
**Module dependency graph:**
``` ```
model ← parser songbook.tex # Main document (title page, TOC, song inputs)
model ← layout songbook-style.sty # Style package (geometry, fonts, leadsheets config)
model ← renderer-pdf songs/ # One .tex file per song
parser, layout, renderer-pdf ← app fonts/ # Font files (UnifrakturMaguntia for titles)
app ← cli (Clikt) images/ # Filler images (empty for now)
app, parser ← gui (Compose Desktop) Makefile # Build rules (lualatex, two passes)
output/ # Generated PDF (gitignored)
``` ```
`model` is the foundation with no dependencies — all data classes, the `FontMetrics` interface, and the `BookRenderer` interface live here. The `FontMetrics` abstraction decouples layout from rendering: `PdfFontMetrics` is the real implementation (in renderer-pdf), `StubFontMetrics` is used in layout tests. ## How It Works
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this. Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
## Key Types - Chords above lyrics in regular weight, black
- No verse labels (verses separated by blank lines)
- `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` - Metadata (Worte/Weise) at bottom of each song page
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image) - Reference book cross-references (MO, PfLB) in footer
- `Foreword``quote`, `paragraphs`, `signatures` — parsed from a plain-text file - Each song starts on a new page
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage` - A5 twoside format with page numbers at bottom-outer
- `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
## Song Format ## Song Format
ChordPro-compatible `.chopro`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples. Each song uses the `leadsheets` `song` environment:
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }` ```latex
\begin{song}{
title = Song Title,
lyrics = Lyricist,
composer = Composer,
key = G,
mundorgel = 42,
pfadfinderliederbuch = 118,
note = {Optional note text.},
}
**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. \begin{verse}
\chord{G}Lyrics with \chord{D}chords above. \\
Next \chord{C}line here.
\end{verse}
**Notes block:** `{start_of_notes}` / `{son}``{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song. \begin{verse}
Second verse without chords (or with).
\end{verse}
**Inline image:** `{image: path}` — embeds an image within a song section. \end{song}
```
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`). **Important constraints:**
- Use `\\` for line breaks within verses (not blank lines)
- Never place two `\chord{}` commands without a space between them — split compound words with a hyphen: `\chord{D}Abend- \chord{A}zeit.`
- Custom properties: `alias`, `note`, `mundorgel`, `pfadfinderliederbuch`
- Verse types: `verse` (no label), `verse*` (for custom-labeled sections like Kanon, Ref.)
- `musicsymbols` library skipped (requires `musix11` font not installed)
## Configuration ## Style Details (songbook-style.sty)
`songbook.yaml` at the project root. Key options beyond the basics: - Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm)
- Body font: TeX Gyre Heros (Helvetica clone)
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`) - Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory)
- `layout.metadata_labels``"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:) - Chord format: small, regular weight, black
- `layout.metadata_position``"top"` (after title) or `"bottom"` (bottom of last page) - Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook)
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`) - Reference style based on Carmina Leonis (Pfadfinder scout songbook)
- `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
Tests use `kotlin.test` annotations with Kotest assertions (`shouldBe`, `shouldHaveSize`, etc.) on JUnit 5. Layout tests use `StubFontMetrics` to avoid PDF font dependencies. App integration tests create temp directories with song files and config.
## Package
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).

36
Makefile Normal file
View File

@@ -0,0 +1,36 @@
MAIN = songbook
ENGINE = lualatex
OUTDIR = output
TEXENV = TEXINPUTS=.:$(shell pwd):$(shell pwd)/$(OUTDIR):
FLAGS = --output-directory=$(OUTDIR) --interaction=nonstopmode
.PHONY: all clean distclean
all: $(OUTDIR)/$(MAIN).pdf
$(OUTDIR):
mkdir -p $(OUTDIR)
# Run until page references stabilize (max 5 passes).
# The .songtoc file needs: pass 1 to write, pass 2 to read into TOC,
# pass 3+ to stabilize page numbers after TOC page count changes.
$(OUTDIR)/$(MAIN).pdf: $(MAIN).tex songbook-style.sty songs/*.tex | $(OUTDIR)
@for i in 1 2 3 4 5; do \
echo "=== LaTeX pass $$i ==="; \
$(TEXENV) $(ENGINE) $(FLAGS) $(MAIN).tex || true; \
if [ -f $(OUTDIR)/$(MAIN).songtoc ]; then \
python3 -c "import re;lines=open('$(OUTDIR)/$(MAIN).songtoc').readlines();lines.sort(key=lambda l:re.sub(r'[^a-zäöüß ]','',re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l).group(1).lower()) if re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l) else '');open('$(OUTDIR)/$(MAIN).songtoc','w').writelines(lines)" ; \
fi; \
if [ $$i -ge 3 ] && ! grep -q "Rerun" $(OUTDIR)/$(MAIN).log 2>/dev/null; then \
echo "=== Stable after $$i passes ==="; \
break; \
fi; \
done
clean:
rm -f $(OUTDIR)/*.aux $(OUTDIR)/*.log $(OUTDIR)/*.out \
$(OUTDIR)/*.toc $(OUTDIR)/*.fls $(OUTDIR)/*.fdb_latexmk \
$(OUTDIR)/*.sxd $(OUTDIR)/*.sxc $(OUTDIR)/*.songtoc
distclean: clean
rm -f $(OUTDIR)/$(MAIN).pdf

298
all-songs.tex Normal file
View File

@@ -0,0 +1,298 @@
% Auto-generated list of all songs (alphabetical order)
% Generated by import-songs.py
\input{songs/abends-gehn-die-liebespaare}
\input{songs/abends-treten-elche}
\input{songs/abends-wenn-das-tageslicht}
\input{songs/ade-nun-zur-guten-nacht}
\input{songs/alle-strassen}
\input{songs/alle-die-mit-uns-auf-kaperfahrt}
\input{songs/allzeit-bereit-bundeslied-der-cpd}
\input{songs/almost-heaven}
\input{songs/als-wir-nach-frankreich}
\input{songs/als-wir-noch-knaben-waren}
\input{songs/am-alten-hafen-piratenhafen}
\input{songs/am-brunnen-vor-dem-tore}
\input{songs/am-ural}
\input{songs/am-westermanns-loenstief}
\input{songs/an-de-eck}
\input{songs/an-land}
\input{songs/andre-die-das-land}
\input{songs/auf-vielen-strassen}
\input{songs/balkanlied}
\input{songs/ballade-von-bergen}
\input{songs/ballade-von-der-gemeinsamen-zeit-vorspiel-d-a-d-g}
\input{songs/banner}
\input{songs/bella-ciao}
\input{songs/big-bomb-dolly-aus-dover}
\input{songs/bin-ja-nur-ein-armer-zigeuner}
\input{songs/birkenring}
\input{songs/bis-in-die-roten-morgenstunden}
\input{songs/brennt-die-sonne}
\input{songs/bruder-nun-wird-es-abend}
\input{songs/burschen-burschen}
\input{songs/buendische-vaganten}
\input{songs/buergerlied}
\input{songs/come-by-the-hills}
\input{songs/das-gotenlied}
\input{songs/das-leben}
\input{songs/das-lilienbanner}
\input{songs/das-schiff-im-nebel}
\input{songs/dat-du-min-leewsten-buest}
\input{songs/dein-ist-dein-leben-vorspiel-e-c-g-d}
\input{songs/der-da-vorn-so-laut}
\input{songs/der-geist-der-fahrt}
\input{songs/der-geist-ist-mued}
\input{songs/der-holzschuhmann}
\input{songs/der-kirchenmausrock}
\input{songs/der-kleine-troll}
\input{songs/der-lang-genug}
\input{songs/der-mond-ist-aufgegangen}
\input{songs/der-papagei-ein-vogel-ist}
\input{songs/der-pfahl}
\input{songs/der-rabe}
\input{songs/der-tag-begann}
\input{songs/der-tod-reit-auf}
\input{songs/der-wagen}
\input{songs/der-zug-faehrt-auf}
\input{songs/die-affen-rasen}
\input{songs/die-ballade-vom-roten-haar}
\input{songs/die-blauen-dragoner}
\input{songs/die-daemmerung-faellt}
\input{songs/die-eisenfaust}
\input{songs/die-freie-republik}
\input{songs/die-gedanken}
\input{songs/die-glocken}
\input{songs/die-grauen-nebel}
\input{songs/die-herren-waren-bei-laune}
\input{songs/die-klampfen-erklingen}
\input{songs/die-lappen-hoch}
\input{songs/die-mazurka}
\input{songs/die-nacht-ist-nicht-allein-zum-schlafen-da}
\input{songs/die-roten-fahnen}
\input{songs/die-sandbank}
\input{songs/die-schluchten-des-balkan}
\input{songs/die-sonne-geht}
\input{songs/die-strasse-gleitet}
\input{songs/die-trommel-her}
\input{songs/die-weber}
\input{songs/die-zunft-der-strassenbrueder}
\input{songs/dort-an-dem-ueferchen}
\input{songs/drei-rote-pfiffe}
\input{songs/drei-tropfen-blut-chume-geselle}
\input{songs/du-machst-kleinholz}
\input{songs/durch-die-morgenroten-scheiben}
\input{songs/daemmert-von-fern}
\input{songs/edelweisspiraten}
\input{songs/eh-die-sonne}
\input{songs/ein-hase-sass-im-tiefen-tal}
\input{songs/ein-hotdog-unten-am-hafen}
\input{songs/ein-kleiner-matrose}
\input{songs/ein-landsknecht}
\input{songs/ein-neuer-tag-beginnt}
\input{songs/ein-stolzes-schiff}
\input{songs/eines-morgens-partisanenlied}
\input{songs/eines-morgens-ging}
\input{songs/einst-macht-ich}
\input{songs/endlich-trocknet-der-landstrasse}
\input{songs/endlos-lang}
\input{songs/endlos-sind-jene-strassen}
\input{songs/ensemble-on-est-mieux-intro-e-a-c-h7}
\input{songs/erklingen-leise-lieder}
\input{songs/es-dunkelt-schon}
\input{songs/es-gibt-nur-wasser}
\input{songs/es-ist-an-der-zeit}
\input{songs/es-ist-ein-schnitter}
\input{songs/es-liegen-drei-glaenzende-kugeln}
\input{songs/es-liegt-etwas-auf-den-strassen}
\input{songs/es-soll-sich-der-mensch}
\input{songs/es-tropft-von-helm-und-saebel}
\input{songs/es-war-an-einem-sommertag}
\input{songs/es-war-ein-koenig}
\input{songs/es-war-in-einer-regennacht}
\input{songs/es-wollt-ein-maegdlein}
\input{songs/falado}
\input{songs/fields-of-athenry}
\input{songs/finnlandlied}
\input{songs/fordre-niemand}
\input{songs/fresenhof}
\input{songs/freunde-das-leben-seid-ihr}
\input{songs/frueher-da-war-ich}
\input{songs/fruehling-dringt-in-den-norden}
\input{songs/geburtstagslied}
\input{songs/gehe-nicht-o-gregor}
\input{songs/gelbe-blaetter-fallen-im-wind}
\input{songs/gestern-brueder}
\input{songs/gospodar-dein-grossgut}
\input{songs/griechischer-fruehling}
\input{songs/grosser-gott-wir-loben-dich}
\input{songs/graefin-anne}
\input{songs/gut-wieder-hier-zu-sein}
\input{songs/gute-nacht-kameraden}
\input{songs/hans-spielmann}
\input{songs/hell-strahlt-die-sonne}
\input{songs/heulender-motor}
\input{songs/heute-hier}
\input{songs/hier-waechst-kein-ahorn}
\input{songs/hoch-lebe-der-mann-mit-dem-hut}
\input{songs/hochzeit}
\input{songs/hohe-tannen}
\input{songs/how-many-roads-blowin-in-the-wind}
\input{songs/hymn-intro-e-esus4-e-esus4-e}
\input{songs/hoerst-du-den-wind-bundeslied-der-esm}
\input{songs/ich-kann-dich-sehen}
\input{songs/ich-komme-schon}
\input{songs/ich-komme-dir-zu-sagen-versprechenslied}
\input{songs/ich-moecht-mit-einem-zirkus-ziehn}
\input{songs/ich-reise-uebers-gruene-land}
\input{songs/ich-und-ein-fass-voller-wein}
\input{songs/ich-war-noch-so-jung-bettelvogt}
\input{songs/ihr-huebschen-jungen-reiter}
\input{songs/ihr-woelfe-kommt-und-schliesst-den-kreis}
\input{songs/im-morgennebel}
\input{songs/in-dem-dunklem-wald-von-paganovo}
\input{songs/in-des-waldes-lola}
\input{songs/in-die-sonne}
\input{songs/in-junkers-kneipe}
\input{songs/islandlied}
\input{songs/jalava-lied}
\input{songs/jasmin}
\input{songs/jauchzende-jungen}
\input{songs/jeden-abend-jerchenkow}
\input{songs/jenseits-des-tales}
\input{songs/jubilaeumslied-der-esm}
\input{songs/joerg-von-frundsberg}
\input{songs/kaffee-und-karin}
\input{songs/kameraden-jagt-die-pferde}
\input{songs/kameraden-wann-sehen-wir-uns-wieder}
\input{songs/karl-der-kaefer}
\input{songs/kein-schoener-land}
\input{songs/klingt-ein-lied-durch-die-nacht-piratenlied}
\input{songs/kommt-ihr-menschen}
\input{songs/komodowaran-intro-ad7g-ad7g}
\input{songs/land-der-dunklen-waelder}
\input{songs/lasst-die-banner}
\input{songs/leave-her-johnny}
\input{songs/leut-die-leut}
\input{songs/lord-of-the-dance}
\input{songs/laender-fahrten-abenteuer}
\input{songs/loewen-sind-jetzt-los}
\input{songs/man-sagt}
\input{songs/meersalz-seht}
\input{songs/mein-ganzes-leben}
\input{songs/mein-kleines-boot}
\input{songs/meine-sonne-will-ich-fragen}
\input{songs/michel-warum-weinest-du}
\input{songs/miners-song}
\input{songs/molly-malone}
\input{songs/moorsoldaten}
\input{songs/maedchen-maenner-meister}
\input{songs/nacht-in-portugal}
\input{songs/nachts-auf-dem-dorfplatz}
\input{songs/nachts-steht-hunger}
\input{songs/nehmt-abschied-brueder}
\input{songs/nicht-nur-nebenbei}
\input{songs/nichts-fuer-suesse-ziehharmonika}
\input{songs/noch-lange-sassen-wir}
\input{songs/nordwaerts}
\input{songs/nun-greift-in-die-saiten}
\input{songs/nun-lustig-lustig}
\input{songs/oh-fischer}
\input{songs/oh-bootsmann}
\input{songs/originale-3-strophe}
\input{songs/panama}
\input{songs/papst-sultan}
\input{songs/platoff}
\input{songs/refrain-2x}
\input{songs/rote-ritterscharen}
\input{songs/roter-mond}
\input{songs/roter-wein-im-becher}
\input{songs/santiano}
\input{songs/santiano-2}
\input{songs/scarborough-fair}
\input{songs/schilf-bleicht}
\input{songs/schlaf-mein-bub}
\input{songs/schliess-aug-und-ohr}
\input{songs/sei-der-abend}
\input{songs/she-hangs-her-head-sad-lisa}
\input{songs/siehst-du-die-feuer}
\input{songs/singt-freunde-lasst-die-klampfen}
\input{songs/so-trolln-wir-uns}
\input{songs/sonnenschein-und-wilde-feste}
\input{songs/star-of-county-down}
\input{songs/stiebt-vom-kasbek}
\input{songs/stille-tage}
\input{songs/strassen-auf-und-strassen-ab}
\input{songs/sturm-bricht-los}
\input{songs/sturm-und-drang}
\input{songs/tief-im-busch}
\input{songs/trinklied-vor-dem-abgang}
\input{songs/trommeln-und-pfeifen}
\input{songs/turm-um-uns}
\input{songs/ty-morjak-deutscher-text}
\input{songs/und-am-abend}
\input{songs/und-der-herbst}
\input{songs/und-die-morgenfruehe}
\input{songs/unglueck-vor-mir}
\input{songs/unser-stammesbus}
\input{songs/unter-den-toren}
\input{songs/vagabundenlied}
\input{songs/verliebt-in-du-intro-c-g-a-a-2x}
\input{songs/viva-la-feria}
\input{songs/vom-barette}
\input{songs/von-allen-blauen-huegeln}
\input{songs/von-der-festung-droehnt}
\input{songs/von-ueberall}
\input{songs/wach-nun-auf}
\input{songs/was-gehn-euch-meine}
\input{songs/was-helfen-mir-tausend-dukaten}
\input{songs/was-kann-ich-denn-dafuer}
\input{songs/was-keiner-wagt}
\input{songs/was-sollen-wir-trinken}
\input{songs/weisser-sand}
\input{songs/welle-wogte}
\input{songs/wem-gott-will-rechte-gunst-erweisen}
\input{songs/wenn-alle-bruennlein}
\input{songs/wenn-der-abend-naht}
\input{songs/wenn-der-fruehling-kommt}
\input{songs/wenn-die-bunten-fahnen}
\input{songs/wenn-die-zeit}
\input{songs/wenn-hell-die-goldne-sonne}
\input{songs/wenn-ich-des-morgens}
\input{songs/weronika-mit-w}
\input{songs/what-shall-we-do-drunken-sailor}
\input{songs/whats-right-ye-jacobites}
\input{songs/whiskey-in-the-jar}
\input{songs/wie-ein-fest-nach-langer-trauer}
\input{songs/wie-kommts-dass-du}
\input{songs/wie-schoen-blueht}
\input{songs/wild-rover}
\input{songs/wilde-gesellen}
\input{songs/wilde-reiter}
\input{songs/wildgaense}
\input{songs/wir-drei-wir-gehn-jetzt}
\input{songs/wir-fahren-uebers-weite-meer}
\input{songs/wir-haben-das-sehen}
\input{songs/wir-kamen-einst}
\input{songs/wir-lagen-vor-madagaskar}
\input{songs/wir-lieben-die-stuerme}
\input{songs/wir-rufen-zu-dir-michaelslied}
\input{songs/wir-sassen-in-johnnys-spelunke}
\input{songs/wir-sind-des-geyers}
\input{songs/wir-sind-durch-deutschland-gefahren}
\input{songs/wir-sind-eine-kleine}
\input{songs/wir-sind-kameraden}
\input{songs/wir-sind-wieder-da-st-goar-hymne}
\input{songs/wir-sitzen-im-rostigen-haifsch}
\input{songs/wir-sitzen-zu-pferde}
\input{songs/wir-wollen-zu-land-ausfahren}
\input{songs/wir-wolln-im-gruenen-wald}
\input{songs/wo-schorle-der-apfelschorlen-blues}
\input{songs/wohl-ueber-erde}
\input{songs/wohlauf-die-luft}
\input{songs/wollt-ihr-hoeren}
\input{songs/wos-nur-felsen}
\input{songs/zieh-meiner-strasse}
\input{songs/zogen-einst}
\input{songs/zogen-viele-strassen}
\input{songs/ueber-meiner-heimat}

View File

@@ -1,16 +0,0 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
implementation(project(":parser"))
implementation(project(":layout"))
implementation(project(":renderer-pdf"))
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -1,259 +0,0 @@
package de.pfadfinder.songbook.app
import de.pfadfinder.songbook.model.*
import de.pfadfinder.songbook.parser.*
import de.pfadfinder.songbook.parser.ForewordParser
import de.pfadfinder.songbook.layout.*
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
import mu.KotlinLogging
import java.io.File
import java.io.FileOutputStream
private val logger = KotlinLogging.logger {}
data class BuildResult(
val success: Boolean,
val outputFile: File? = null,
val errors: List<ValidationError> = emptyList(),
val songCount: Int = 0,
val pageCount: Int = 0
)
class SongbookPipeline(private val projectDir: File) {
/**
* Build the songbook PDF.
*
* @param customSongOrder Optional list of song file names in the desired order.
* When provided, songs are sorted to match this order instead of using the
* config-based sort (alphabetical or manual). Files not in this list are
* appended at the end.
* @param onProgress Optional callback invoked with status messages during the build.
*/
fun build(customSongOrder: List<String>? = null, onProgress: ((String) -> Unit)? = null): BuildResult {
// 1. Parse config
onProgress?.invoke("Konfiguration wird geladen...")
val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) {
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
}
logger.info { "Parsing config: ${configFile.absolutePath}" }
val rawConfig = ConfigParser.parse(configFile)
// Resolve font file paths relative to the project directory
val config = resolveFontPaths(rawConfig)
// Validate config (including font file existence)
val configErrors = Validator.validateConfig(config)
if (configErrors.isNotEmpty()) {
return BuildResult(false, errors = configErrors)
}
// 2. Parse songs
val songsDir = File(projectDir, config.songs.directory)
if (!songsDir.exists() || !songsDir.isDirectory) {
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
}
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
if (songFiles.isEmpty()) {
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
}
onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...")
logger.info { "Found ${songFiles.size} song files" }
val songsByFileName = mutableMapOf<String, Song>()
val allErrors = mutableListOf<ValidationError>()
for ((index, file) in songFiles.withIndex()) {
if (index > 0 && index % 50 == 0) {
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
}
try {
val song = ChordProParser.parseFile(file)
val songErrors = Validator.validateSong(song, file.name)
if (songErrors.isNotEmpty()) {
allErrors.addAll(songErrors)
} else {
songsByFileName[file.name] = song
}
} catch (e: Exception) {
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
}
}
if (allErrors.isNotEmpty()) {
return BuildResult(false, errors = allErrors)
}
val songs = songsByFileName.values.toList()
// Sort songs: custom order takes priority, then config-based sort
val sortedSongs = if (customSongOrder != null) {
val orderMap = customSongOrder.withIndex().associate { (index, name) -> name to index }
songs.sortedBy { song ->
val fileName = songsByFileName.entries.find { it.value === song }?.key
orderMap[fileName] ?: Int.MAX_VALUE
}
} else {
when (config.songs.order) {
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
else -> songs // manual order = file order
}
}
logger.info { "Parsed ${sortedSongs.size} songs" }
// 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
onProgress?.invoke("Layout wird berechnet...")
val fontMetrics = PdfFontMetrics()
val measurementEngine = MeasurementEngine(fontMetrics, config)
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
// 4. Generate TOC and paginate
val tocGenerator = TocGenerator(config)
val estimatedTocPages = tocGenerator.estimateTocPages(sortedSongs)
// Intro page takes 2 pages (title + blank back) for double-sided printing
val introPages = if (config.intro?.enabled == true) 2 else 0
// Foreword always takes 2 pages (for double-sided printing)
val forewordPages = if (foreword != null) 2 else 0
val headerPages = introPages + estimatedTocPages + forewordPages
val paginationEngine = PaginationEngine(config)
val pages = paginationEngine.paginate(measuredSongs, headerPages)
// 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(
introPages = introPages,
tocPages = tocPages,
pages = allPages,
tocEntries = tocEntries
)
val totalPages = introPages + tocPages + pages.size
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
// 5. Render PDF
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
val outputDir = File(projectDir, config.output.directory)
outputDir.mkdirs()
val outputFile = File(outputDir, config.output.filename)
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
val renderer = PdfBookRenderer()
FileOutputStream(outputFile).use { fos ->
renderer.render(layoutResult, config, fos)
}
logger.info { "Build complete: ${sortedSongs.size} songs, $totalPages pages" }
return BuildResult(
success = true,
outputFile = outputFile,
songCount = sortedSongs.size,
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> {
val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) {
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
}
val rawConfig = ConfigParser.parse(configFile)
val config = resolveFontPaths(rawConfig)
val errors = mutableListOf<ValidationError>()
errors.addAll(Validator.validateConfig(config))
val songsDir = File(projectDir, config.songs.directory)
if (!songsDir.exists()) {
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
return errors
}
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
for (file in songFiles) {
try {
val song = ChordProParser.parseFile(file)
errors.addAll(Validator.validateSong(song, file.name))
} catch (e: Exception) {
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
}
}
return errors
}
}

View File

@@ -1,534 +0,0 @@
package de.pfadfinder.songbook.app
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldNotBeEmpty
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.io.File
import kotlin.test.Test
class SongbookPipelineTest {
private fun createTempProject(): File {
val dir = kotlin.io.path.createTempDirectory("songbook-test").toFile()
dir.deleteOnExit()
return dir
}
private fun writeConfig(projectDir: File, config: String = defaultConfig()) {
File(projectDir, "songbook.yaml").writeText(config)
}
private fun defaultConfig(
songsDir: String = "./songs",
outputDir: String = "./output",
outputFilename: String = "liederbuch.pdf",
order: String = "alphabetical"
): String = """
book:
title: "Test Liederbuch"
format: "A5"
songs:
directory: "$songsDir"
order: "$order"
fonts:
lyrics:
family: "Helvetica"
size: 10
chords:
family: "Helvetica"
size: 9
title:
family: "Helvetica"
size: 14
metadata:
family: "Helvetica"
size: 8
toc:
family: "Helvetica"
size: 9
layout:
margins:
top: 15
bottom: 15
inner: 20
outer: 12
images:
directory: "./images"
output:
directory: "$outputDir"
filename: "$outputFilename"
""".trimIndent()
private fun writeSongFile(songsDir: File, filename: String, content: String) {
songsDir.mkdirs()
File(songsDir, filename).writeText(content)
}
private fun sampleSong(title: String = "Test Song"): String = """
{title: $title}
{start_of_verse}
[Am]Hello [C]world
This is a test
{end_of_verse}
""".trimIndent()
// --- build() tests ---
@Test
fun `build returns error when songbook yaml is missing`() {
val projectDir = createTempProject()
try {
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "songbook.yaml not found"
result.outputFile.shouldBeNull()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error when songs directory does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "Songs directory not found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error when songs directory is empty`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
File(projectDir, "songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "No song files found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for invalid config with zero margins`() {
val projectDir = createTempProject()
try {
val config = """
book:
title: "Test"
layout:
margins:
top: 0
bottom: 15
inner: 20
outer: 12
""".trimIndent()
writeConfig(projectDir, config)
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for song with missing title`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad_song.chopro", """
{start_of_verse}
[Am]Hello world
{end_of_verse}
""".trimIndent())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("title", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for song with no sections`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "empty_song.chopro", "{title: Empty Song}")
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("section", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build succeeds with valid project`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong("Alpha Song"))
writeSongFile(songsDir, "song2.chopro", sampleSong("Beta Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.errors.shouldBeEmpty()
result.outputFile.shouldNotBeNull()
result.outputFile!!.exists().shouldBeTrue()
result.songCount shouldBe 2
result.pageCount shouldBeGreaterThan 0
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build creates output directory if it does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(outputDir = "./out/build"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
File(projectDir, "out/build").exists().shouldBeTrue()
result.outputFile!!.exists().shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build with alphabetical order sorts songs by title`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(order = "alphabetical"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "z_first.chopro", sampleSong("Zebra Song"))
writeSongFile(songsDir, "a_second.chopro", sampleSong("Alpha Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 2
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build with manual order preserves file order`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(order = "manual"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "02_second.chopro", sampleSong("Second Song"))
writeSongFile(songsDir, "01_first.chopro", sampleSong("First Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 2
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build recognizes cho extension`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.cho", sampleSong("Cho Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build recognizes crd extension`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.crd", sampleSong("Crd Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build ignores non-song files in songs directory`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong("Real Song"))
writeSongFile(songsDir, "readme.txt", "Not a song")
writeSongFile(songsDir, "notes.md", "# Notes")
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build output file has correct name`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(outputFilename = "my-book.pdf"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.outputFile!!.name shouldBe "my-book.pdf"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build pageCount includes toc pages`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
// At least 1 content page + TOC pages (minimum 2 for even count)
result.pageCount shouldBeGreaterThan 1
} finally {
projectDir.deleteRecursively()
}
}
// --- validate() tests ---
@Test
fun `validate returns error when songbook yaml is missing`() {
val projectDir = createTempProject()
try {
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors shouldHaveSize 1
errors[0].message shouldContain "songbook.yaml not found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate returns error when songs directory does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
errors.any { it.message.contains("Songs directory not found") }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate returns empty list for valid project`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports config errors`() {
val projectDir = createTempProject()
try {
val config = """
layout:
margins:
top: 0
bottom: 0
inner: 0
outer: 0
""".trimIndent()
writeConfig(projectDir, config)
// Still need songs dir to exist for full validate
File(projectDir, "./songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors shouldHaveSize 4
errors.all { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports song validation errors`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad_song.chopro", "{title: }")
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports errors for multiple invalid songs`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad1.chopro", "{title: Good Title}") // no sections
writeSongFile(songsDir, "bad2.chopro", "{title: Another Title}") // no sections
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
errors.size shouldBeGreaterThan 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate with empty songs directory returns no song errors`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
File(projectDir, "songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
// No errors because there are no song files to validate
errors.shouldBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
// --- BuildResult data class tests ---
@Test
fun `BuildResult defaults are correct`() {
val result = BuildResult(success = false)
result.success.shouldBeFalse()
result.outputFile.shouldBeNull()
result.errors.shouldBeEmpty()
result.songCount shouldBe 0
result.pageCount shouldBe 0
}
@Test
fun `BuildResult with all fields set`() {
val file = File("/tmp/test.pdf")
val errors = listOf(de.pfadfinder.songbook.parser.ValidationError("test", 1, "error"))
val result = BuildResult(
success = true,
outputFile = file,
errors = errors,
songCount = 5,
pageCount = 10
)
result.success.shouldBeTrue()
result.outputFile shouldBe file
result.errors shouldHaveSize 1
result.songCount shouldBe 5
result.pageCount shouldBe 10
}
}

View File

@@ -1,4 +0,0 @@
plugins {
id("org.jetbrains.compose") version "1.7.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
}

View File

@@ -1,12 +0,0 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
}

View File

@@ -1,18 +0,0 @@
plugins {
kotlin("jvm")
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@@ -1,17 +0,0 @@
plugins {
id("songbook-conventions")
application
}
application {
mainClass.set("de.pfadfinder.songbook.cli.MainKt")
}
dependencies {
implementation(project(":app"))
implementation(project(":model"))
implementation(project(":parser"))
implementation("com.github.ajalt.clikt:clikt:5.0.3")
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
}

View File

@@ -1,37 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import de.pfadfinder.songbook.app.SongbookPipeline
import java.io.File
class BuildCommand : CliktCommand(name = "build") {
override fun help(context: Context) = "Build the songbook PDF"
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
override fun run() {
val dir = File(projectDir).absoluteFile
echo("Building songbook from: ${dir.path}")
val pipeline = SongbookPipeline(dir)
val result = pipeline.build(onProgress = { msg -> echo(msg) })
if (result.success) {
echo("Build successful!")
echo(" Songs: ${result.songCount}")
echo(" Pages: ${result.pageCount}")
echo(" Output: ${result.outputFile?.absolutePath}")
} else {
echo("Build failed with ${result.errors.size} error(s):", err = true)
for (error in result.errors) {
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
echo(" [$location] ${error.message}", err = true)
}
throw ProgramResult(1)
}
}
}

View File

@@ -1,15 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
class SongbookCli : CliktCommand(name = "songbook") {
override fun run() = Unit
}
fun main(args: Array<String>) {
SongbookCli()
.subcommands(BuildCommand(), ValidateCommand())
.main(args)
}

View File

@@ -1,34 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import de.pfadfinder.songbook.app.SongbookPipeline
import java.io.File
class ValidateCommand : CliktCommand(name = "validate") {
override fun help(context: Context) = "Validate all song files"
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
override fun run() {
val dir = File(projectDir).absoluteFile
echo("Validating songbook in: ${dir.path}")
val pipeline = SongbookPipeline(dir)
val errors = pipeline.validate()
if (errors.isEmpty()) {
echo("All songs are valid!")
} else {
echo("Found ${errors.size} error(s):", err = true)
for (error in errors) {
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
echo(" [$location] ${error.message}", err = true)
}
throw ProgramResult(1)
}
}
}

Binary file not shown.

View File

@@ -1 +0,0 @@
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk

Binary file not shown.

View File

@@ -1,7 +0,0 @@
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
View File

@@ -1,248 +0,0 @@
#!/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
View File

@@ -1,93 +0,0 @@
@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

View File

@@ -1,21 +0,0 @@
plugins {
id("songbook-conventions")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
}
dependencies {
implementation(project(":app"))
implementation(project(":model"))
implementation(project(":parser"))
implementation(compose.desktop.currentOs)
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
implementation("org.apache.pdfbox:pdfbox:3.0.4")
}
compose.desktop {
application {
mainClass = "de.pfadfinder.songbook.gui.AppKt"
}
}

View File

@@ -1,447 +0,0 @@
package de.pfadfinder.songbook.gui
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import de.pfadfinder.songbook.app.BuildResult
import de.pfadfinder.songbook.app.SongbookPipeline
import de.pfadfinder.songbook.parser.ChordProParser
import de.pfadfinder.songbook.parser.ConfigParser
import de.pfadfinder.songbook.parser.ValidationError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Desktop
import java.io.File
import javax.swing.JFileChooser
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Songbook Builder"
) {
App()
}
}
data class SongEntry(val fileName: String, val title: String)
@Composable
@Preview
fun App() {
var projectPath by remember { mutableStateOf("") }
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
var originalSongs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
var songsOrderConfig by remember { mutableStateOf("alphabetical") }
var isCustomOrder by remember { mutableStateOf(false) }
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
var isRunning by remember { mutableStateOf(false) }
var isLoadingSongs by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
val previewState = remember { PdfPreviewState() }
val scope = rememberCoroutineScope()
val reorderEnabled = songsOrderConfig != "alphabetical"
fun loadSongs(path: String) {
val projectDir = File(path)
songs = emptyList()
originalSongs = emptyList()
isCustomOrder = false
if (!projectDir.isDirectory) return
isLoadingSongs = true
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
scope.launch {
val (loadedSongs, order) = withContext(Dispatchers.IO) {
val configFile = File(projectDir, "songbook.yaml")
var songsDir: File
var orderConfig = "alphabetical"
if (configFile.exists()) {
try {
val config = ConfigParser.parse(configFile)
songsDir = File(projectDir, config.songs.directory)
orderConfig = config.songs.order
} catch (_: Exception) {
songsDir = File(projectDir, "songs")
}
} else {
songsDir = File(projectDir, "songs")
}
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig)
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
val loaded = songFiles.mapNotNull { file ->
try {
val song = ChordProParser.parseFile(file)
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
} catch (_: Exception) {
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
}
}
Pair(loaded, orderConfig)
}
songsOrderConfig = order
songs = if (order == "alphabetical") {
loadedSongs.sortedBy { it.title.lowercase() }
} else {
loadedSongs
}
originalSongs = songs.toList()
isLoadingSongs = false
statusMessages = if (loadedSongs.isNotEmpty()) {
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
} else {
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
}
}
}
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
SelectionContainer {
Column(modifier = Modifier.padding(16.dp)) {
// Project directory selection
Text(
text = "Songbook Builder",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Projektverzeichnis:", fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = projectPath,
onValueChange = {
projectPath = it
loadSongs(it)
},
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text("Pfad zum Projektverzeichnis...") }
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Projektverzeichnis auswählen"
if (projectPath.isNotBlank()) {
currentDirectory = File(projectPath)
}
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
projectPath = chooser.selectedFile.absolutePath
loadSongs(projectPath)
}
},
enabled = !isRunning
) {
Text("Durchsuchen...")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Central content area: song list or preview panel
if (previewState.isVisible) {
// Show preview panel
PdfPreviewPanel(
state = previewState,
modifier = Modifier.weight(1f)
)
} else {
// Song list header with optional reset button
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Lieder (${songs.size}):",
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
if (reorderEnabled && isCustomOrder) {
Button(
onClick = {
songs = originalSongs.toList()
isCustomOrder = false
},
enabled = !isRunning
) {
Text("Reihenfolge zuruecksetzen")
}
}
}
Spacer(modifier = Modifier.height(4.dp))
if (isLoadingSongs) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text("Lieder werden geladen...", color = Color.Gray)
}
}
} else if (songs.isEmpty() && projectPath.isNotBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Text(
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
color = Color.Gray,
modifier = Modifier.padding(8.dp)
)
}
} else if (projectPath.isBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Text(
"Bitte ein Projektverzeichnis auswaehlen.",
color = Color.Gray,
modifier = Modifier.padding(8.dp)
)
}
} else {
ReorderableSongList(
songs = songs,
reorderEnabled = reorderEnabled,
onReorder = { newList ->
songs = newList
isCustomOrder = true
},
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
if (projectPath.isBlank()) return@Button
isRunning = true
lastBuildResult = null
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
scope.launch {
// Build custom song order from the current GUI list
val customOrder = if (isCustomOrder) {
songs.map { it.fileName }
} else {
null
}
val result = withContext(Dispatchers.IO) {
try {
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
}
} catch (e: Exception) {
BuildResult(
success = false,
errors = listOf(
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
)
)
}
}
lastBuildResult = result
statusMessages = if (result.success) {
listOf(
StatusMessage(
"Buch erfolgreich erstellt! ${result.songCount} Lieder, ${result.pageCount} Seiten.",
MessageType.SUCCESS
),
StatusMessage(
"Ausgabedatei: ${result.outputFile?.absolutePath ?: "unbekannt"}",
MessageType.INFO
)
)
} else {
result.errors.map { error ->
val location = buildString {
if (error.file != null) append(error.file)
if (error.line != null) append(":${error.line}")
}
val prefix = if (location.isNotEmpty()) "[$location] " else ""
StatusMessage("$prefix${error.message}", MessageType.ERROR)
}
}
isRunning = false
// Automatically load preview after successful build
if (result.success && result.outputFile != null) {
previewState.loadPdf(result.outputFile!!)
}
}
},
enabled = !isRunning && projectPath.isNotBlank()
) {
Text("Buch erstellen")
}
Button(
onClick = {
if (projectPath.isBlank()) return@Button
isRunning = true
lastBuildResult = null
statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO))
scope.launch {
val errors = withContext(Dispatchers.IO) {
try {
SongbookPipeline(File(projectPath)).validate()
} catch (e: Exception) {
listOf(
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
)
}
}
statusMessages = if (errors.isEmpty()) {
listOf(StatusMessage("Validierung erfolgreich! Keine Fehler gefunden.", MessageType.SUCCESS))
} else {
errors.map { error ->
val location = buildString {
if (error.file != null) append(error.file)
if (error.line != null) append(":${error.line}")
}
val prefix = if (location.isNotEmpty()) "[$location] " else ""
StatusMessage("$prefix${error.message}", MessageType.ERROR)
}
}
isRunning = false
}
},
enabled = !isRunning && projectPath.isNotBlank()
) {
Text("Validieren")
}
if (lastBuildResult?.success == true && lastBuildResult?.outputFile != null) {
Button(
onClick = {
lastBuildResult?.outputFile?.let { file ->
try {
Desktop.getDesktop().open(file)
} catch (e: Exception) {
statusMessages = statusMessages + StatusMessage(
"PDF konnte nicht geoeffnet werden: ${e.message}",
MessageType.ERROR
)
}
}
},
enabled = !isRunning
) {
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")
}
}
if (isRunning) {
Spacer(modifier = Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically),
strokeWidth = 2.dp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Status/log area
Text("Status:", fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
) {
val logListState = rememberLazyListState()
LazyColumn(
state = logListState,
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
) {
if (statusMessages.isEmpty()) {
item {
Text(
"Bereit.",
color = Color.Gray,
modifier = Modifier.padding(4.dp)
)
}
}
items(statusMessages) { msg ->
Text(
text = msg.text,
color = when (msg.type) {
MessageType.ERROR -> MaterialTheme.colors.error
MessageType.SUCCESS -> Color(0xFF2E7D32)
MessageType.INFO -> Color.Unspecified
},
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp)
)
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(logListState)
)
}
}
}
}
}
}
enum class MessageType {
INFO, SUCCESS, ERROR
}
data class StatusMessage(val text: String, val type: MessageType)

View File

@@ -1,221 +0,0 @@
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(">")
}
}
}
}

View File

@@ -1,142 +0,0 @@
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)
)
}
}

BIN
images/img-000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
images/img-001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
images/img-002.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/img-003.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
images/img-004.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
images/img-005.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/img-006.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
images/img-007.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/img-008.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/img-009.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
images/img-010.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/img-011.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
images/img-013.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
images/img-014.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
images/img-015.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
images/img-016.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

BIN
images/img-017.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
images/img-018.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
images/img-019.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
images/img-020.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

BIN
images/img-021.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/img-022.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
images/img-024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
images/img-025.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
images/img-026.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
images/img-027.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
images/img-028.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
images/img-029.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
images/img-030.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
images/img-031.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
images/img-032.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
images/img-033.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

BIN
images/img-034.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
images/img-035.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
images/img-036.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/img-038.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
images/img-039.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
images/img-040.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
images/img-041.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
images/img-042.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
images/img-043.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
images/img-044.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/img-045.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
images/img-046.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/img-047.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
images/img-048.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
images/img-049.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/img-050.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
images/img-051.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/img-053.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
images/img-054.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
images/img-055.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
images/img-056.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
images/img-057.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
images/img-059.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

BIN
images/img-060.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
images/img-061.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/img-062.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
images/img-063.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
images/img-064.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
images/img-065.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/img-066.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
images/img-067.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
images/img-068.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
images/img-069.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
images/img-070.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/img-071.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
images/img-072.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
images/img-073.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/img-074.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
images/img-075.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/img-076.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
images/img-077.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Some files were not shown because too many files have changed in this diff Show More