From 8c92c7d78bcf5a128f4ed2c44c6ae6950625b8ea Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Tue, 17 Mar 2026 09:38:25 +0100 Subject: [PATCH] feat: support rich multi-paragraph notes with formatting (Closes #7) Add {start_of_notes}/{end_of_notes} (and short forms {son}/{eon}) block directives to ChordProParser for multi-paragraph note content. Blank lines within the block separate paragraphs. The renderer now word-wraps note paragraphs to fit within the content width. MeasurementEngine estimates wrapped line count for more accurate height calculations. Co-Authored-By: Claude Opus 4.6 --- .../songbook/layout/MeasurementEngine.kt | 20 +++- .../songbook/parser/ChordProParser.kt | 40 ++++++++ .../songbook/parser/ChordProParserTest.kt | 92 +++++++++++++++++++ .../songbook/renderer/pdf/PdfBookRenderer.kt | 27 ++++-- 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt index 0bbad8b..fc3bca9 100644 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt @@ -58,11 +58,23 @@ class MeasurementEngine( heightMm += config.layout.verseSpacing } - // Notes at bottom + // Notes at bottom (with word-wrap estimation for multi-paragraph notes) if (song.notes.isNotEmpty()) { - heightMm += 1.5f // gap - for (note in song.notes) { - heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f + heightMm += 1.5f // gap before notes + val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f + // A5 content width in mm = 148 - inner margin - outer margin + val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer + + for ((idx, note) in song.notes.withIndex()) { + // Estimate how many wrapped lines this note paragraph needs + val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size) + val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt()) + heightMm += metaLineHeight * estimatedLines + + // Paragraph spacing between note paragraphs + if (idx < song.notes.size - 1) { + heightMm += metaLineHeight * 0.3f + } } } diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt index a386b3c..995b931 100644 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt @@ -25,6 +25,17 @@ object ChordProParser { var currentLabel: String? = null var currentLines = mutableListOf() + // Notes block state + var inNotesBlock = false + var currentNoteParagraph = StringBuilder() + + fun flushNoteParagraph() { + if (currentNoteParagraph.isNotEmpty()) { + notes.add(currentNoteParagraph.toString().trim()) + currentNoteParagraph = StringBuilder() + } + } + fun flushSection() { if (currentType != null) { sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList())) @@ -37,6 +48,27 @@ object ChordProParser { for (rawLine in lines) { val line = rawLine.trimEnd() + // Inside a notes block: collect lines as paragraphs + if (inNotesBlock) { + if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) { + val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase() + if (inner == "end_of_notes" || inner == "eon") { + flushNoteParagraph() + inNotesBlock = false + continue + } + } + if (line.isBlank()) { + flushNoteParagraph() + } else { + if (currentNoteParagraph.isNotEmpty()) { + currentNoteParagraph.append(" ") + } + currentNoteParagraph.append(line.trim()) + } + continue + } + // Skip comments if (line.trimStart().startsWith("#")) continue @@ -97,6 +129,14 @@ object ChordProParser { "end_of_repeat", "eor" -> { flushSection() } + "start_of_notes", "son" -> { + inNotesBlock = true + } + "end_of_notes", "eon" -> { + // Should have been handled in the notes block above + flushNoteParagraph() + inNotesBlock = false + } "chorus" -> { flushSection() sections.add(SongSection(type = SectionType.CHORUS)) diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt index 48c0e80..7a2a292 100644 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt @@ -485,4 +485,96 @@ class ChordProParserTest { line.segments[2].chord shouldBe "G" line.segments[2].text shouldBe "End" } + + @Test + fun `parse notes block with multiple paragraphs`() { + val input = """ + {title: Song} + {start_of_notes} + First paragraph of the notes. + It continues on the next line. + + Second paragraph with different content. + {end_of_notes} + {start_of_verse} + text + {end_of_verse} + """.trimIndent() + val song = ChordProParser.parse(input) + song.notes shouldHaveSize 2 + song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line." + song.notes[1] shouldBe "Second paragraph with different content." + } + + @Test + fun `parse notes block with single paragraph`() { + val input = """ + {title: Song} + {start_of_notes} + A single note paragraph. + {end_of_notes} + {start_of_verse} + text + {end_of_verse} + """.trimIndent() + val song = ChordProParser.parse(input) + song.notes shouldHaveSize 1 + song.notes[0] shouldBe "A single note paragraph." + } + + @Test + fun `parse notes block with short directives son eon`() { + val input = """ + {title: Song} + {son} + Short form notes. + {eon} + {start_of_verse} + text + {end_of_verse} + """.trimIndent() + val song = ChordProParser.parse(input) + song.notes shouldHaveSize 1 + song.notes[0] shouldBe "Short form notes." + } + + @Test + fun `notes block and single note directives combine`() { + val input = """ + {title: Song} + {note: Single line note} + {start_of_notes} + Block note paragraph. + {end_of_notes} + {start_of_verse} + text + {end_of_verse} + """.trimIndent() + val song = ChordProParser.parse(input) + song.notes shouldHaveSize 2 + song.notes[0] shouldBe "Single line note" + song.notes[1] shouldBe "Block note paragraph." + } + + @Test + fun `parse notes block with three paragraphs`() { + val input = """ + {title: Song} + {start_of_notes} + Paragraph one. + + Paragraph two. + + Paragraph three. + {end_of_notes} + {start_of_verse} + text + {end_of_verse} + """.trimIndent() + val song = ChordProParser.parse(input) + song.notes shouldHaveSize 3 + song.notes[0] shouldBe "Paragraph one." + song.notes[1] shouldBe "Paragraph two." + song.notes[2] shouldBe "Paragraph three." + } } diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt index fd12761..e35d141 100644 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt @@ -226,19 +226,28 @@ class PdfBookRenderer : BookRenderer { y -= config.layout.verseSpacing / 0.3528f } - // Render notes at the bottom + // Render notes at the bottom (with word-wrap for multi-paragraph notes) if (pageIndex == 0 && song.notes.isNotEmpty()) { y -= 4f val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) val metaSize = config.fonts.metadata.size - for (note in song.notes) { - cb.beginText() - cb.setFontAndSize(metaFont, metaSize) - cb.setColorFill(Color.GRAY) - cb.setTextMatrix(leftMargin, y - metaSize) - cb.showText(note) - cb.endText() - y -= metaSize * 1.5f + val noteLineHeight = metaSize * 1.5f + + for ((idx, note) in song.notes.withIndex()) { + val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth) + for (wrappedLine in wrappedLines) { + cb.beginText() + cb.setFontAndSize(metaFont, metaSize) + cb.setColorFill(Color.GRAY) + cb.setTextMatrix(leftMargin, y - metaSize) + cb.showText(wrappedLine) + cb.endText() + y -= noteLineHeight + } + // Add paragraph spacing between note paragraphs + if (idx < song.notes.size - 1) { + y -= noteLineHeight * 0.3f + } } }