Compare commits
2 Commits
v0.4.0-lat
...
79688be51e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79688be51e | ||
|
|
8e4728c55a |
37
.gitignore
vendored
@@ -1,29 +1,22 @@
|
||||
# LaTeX build artifacts
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
*.synctex(busy)
|
||||
*.sxd
|
||||
*.sxc
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
buildSrc/build/
|
||||
|
||||
# Output directory
|
||||
output/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Output
|
||||
output/
|
||||
|
||||
# Kotlin
|
||||
*.class
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
__pycache__/
|
||||
|
||||
118
CLAUDE.md
@@ -2,83 +2,79 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build Commands
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Build the songbook PDF (two-pass for TOC)
|
||||
make
|
||||
# Build everything
|
||||
gradle build
|
||||
|
||||
# Remove auxiliary files
|
||||
make clean
|
||||
# Run all tests
|
||||
gradle test
|
||||
|
||||
# Remove everything including PDF
|
||||
make distclean
|
||||
# Run tests for a specific module
|
||||
gradle :parser:test
|
||||
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 LuaLaTeX (TeX Live) and the `leadsheets` package.
|
||||
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
|
||||
|
||||
## Project Structure
|
||||
## Architecture
|
||||
|
||||
**Pipeline:** Parse → Measure → Paginate → Render
|
||||
|
||||
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||
2. `ChordProParser` reads `.chopro` files → `Song` objects
|
||||
3. `Validator` checks config and songs
|
||||
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||
5. `TocGenerator` estimates TOC page count and creates entries
|
||||
6. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||
7. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||
|
||||
**Module dependency graph:**
|
||||
```
|
||||
songbook.tex # Main document (title page, TOC, song inputs)
|
||||
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
||||
songs/ # One .tex file per song
|
||||
fonts/ # Font files (UnifrakturMaguntia for titles)
|
||||
images/ # Filler images (empty for now)
|
||||
Makefile # Build rules (lualatex, two passes)
|
||||
output/ # Generated PDF (gitignored)
|
||||
model ← parser
|
||||
model ← layout
|
||||
model ← renderer-pdf
|
||||
parser, layout, renderer-pdf ← app
|
||||
app ← cli (Clikt)
|
||||
app, parser ← gui (Compose Desktop)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
`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.
|
||||
|
||||
Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
|
||||
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
||||
- Chords above lyrics in regular weight, black
|
||||
- No verse labels (verses separated by blank lines)
|
||||
- Metadata (Worte/Weise) at bottom of each song page
|
||||
- Reference book cross-references (MO, PfLB) in footer
|
||||
- Each song starts on a new page
|
||||
- A5 twoside format with page numbers at bottom-outer
|
||||
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this.
|
||||
|
||||
## Key Types
|
||||
|
||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||
|
||||
## Song Format
|
||||
|
||||
Each song uses the `leadsheets` `song` environment:
|
||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||
|
||||
```latex
|
||||
\begin{song}{
|
||||
title = Song Title,
|
||||
lyrics = Lyricist,
|
||||
composer = Composer,
|
||||
key = G,
|
||||
mundorgel = 42,
|
||||
pfadfinderliederbuch = 118,
|
||||
note = {Optional note text.},
|
||||
}
|
||||
## Test Patterns
|
||||
|
||||
\begin{verse}
|
||||
\chord{G}Lyrics with \chord{D}chords above. \\
|
||||
Next \chord{C}line here.
|
||||
\end{verse}
|
||||
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.
|
||||
|
||||
\begin{verse}
|
||||
Second verse without chords (or with).
|
||||
\end{verse}
|
||||
## Package
|
||||
|
||||
\end{song}
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
## Style Details (songbook-style.sty)
|
||||
|
||||
- Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm)
|
||||
- Body font: TeX Gyre Heros (Helvetica clone)
|
||||
- Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory)
|
||||
- Chord format: small, regular weight, black
|
||||
- Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook)
|
||||
- Reference style based on Carmina Leonis (Pfadfinder scout songbook)
|
||||
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
||||
|
||||
36
Makefile
@@ -1,36 +0,0 @@
|
||||
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
@@ -1,298 +0,0 @@
|
||||
% 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}
|
||||
16
app/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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.*
|
||||
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) {
|
||||
|
||||
fun build(): BuildResult {
|
||||
// 1. Parse config
|
||||
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 config = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
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")))
|
||||
}
|
||||
|
||||
logger.info { "Found ${songFiles.size} song files" }
|
||||
|
||||
val songs = mutableListOf<Song>()
|
||||
val allErrors = mutableListOf<ValidationError>()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songs.add(song)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
// Sort songs
|
||||
val sortedSongs = when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
|
||||
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
|
||||
val fontMetrics = PdfFontMetrics()
|
||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||
|
||||
// 4. Generate TOC and paginate
|
||||
val tocGenerator = TocGenerator(config)
|
||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||
|
||||
// Foreword always takes 2 pages (for double-sided printing)
|
||||
val forewordPages = if (foreword != null) 2 else 0
|
||||
|
||||
val paginationEngine = PaginationEngine(config)
|
||||
val pages = paginationEngine.paginate(measuredSongs, tocPages + forewordPages)
|
||||
|
||||
val tocEntries = tocGenerator.generate(pages, tocPages + forewordPages)
|
||||
|
||||
// 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(
|
||||
tocPages = tocPages,
|
||||
pages = allPages,
|
||||
tocEntries = tocEntries
|
||||
)
|
||||
|
||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
||||
|
||||
// 5. Render PDF
|
||||
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, ${pages.size + tocPages} pages" }
|
||||
|
||||
return BuildResult(
|
||||
success = true,
|
||||
outputFile = outputFile,
|
||||
songCount = sortedSongs.size,
|
||||
pageCount = pages.size + tocPages
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val config = ConfigParser.parse(configFile)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("org.jetbrains.compose") version "1.7.3" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
|
||||
}
|
||||
12
buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
||||
}
|
||||
18
buildSrc/src/main/kotlin/songbook-conventions.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
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()
|
||||
}
|
||||
17
cli/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
20
gui/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
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")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "de.pfadfinder.songbook.gui.AppKt"
|
||||
}
|
||||
}
|
||||
347
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
@@ -0,0 +1,347 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
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.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 statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
val songsDir = if (configFile.exists()) {
|
||||
try {
|
||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
||||
File(projectDir, config.songs.directory)
|
||||
} catch (_: Exception) {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
} else {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
songs = songFiles.mapNotNull { file ->
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
||||
} catch (_: Exception) {
|
||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
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))
|
||||
|
||||
// Song list
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswählen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(songs) { song ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(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 {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build()
|
||||
} 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
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Buch erstellen")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung läuft...", 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 geöffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF öffnen")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |