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 <noreply@anthropic.com>
This commit was merged in pull request #11.
This commit is contained in:
shahondin1624
2026-03-17 09:38:25 +01:00
parent 0139327034
commit 8c92c7d78b
4 changed files with 166 additions and 13 deletions

View File

@@ -58,11 +58,23 @@ class MeasurementEngine(
heightMm += config.layout.verseSpacing heightMm += config.layout.verseSpacing
} }
// Notes at bottom // Notes at bottom (with word-wrap estimation for multi-paragraph notes)
if (song.notes.isNotEmpty()) { if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap heightMm += 1.5f // gap before notes
for (note in song.notes) { val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f // A5 content width in mm = 148 - inner margin - outer margin
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
for ((idx, note) in song.notes.withIndex()) {
// Estimate how many wrapped lines this note paragraph needs
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
heightMm += metaLineHeight * estimatedLines
// Paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
heightMm += metaLineHeight * 0.3f
}
} }
} }

View File

@@ -25,6 +25,17 @@ object ChordProParser {
var currentLabel: String? = null var currentLabel: String? = null
var currentLines = mutableListOf<SongLine>() var currentLines = mutableListOf<SongLine>()
// Notes block state
var inNotesBlock = false
var currentNoteParagraph = StringBuilder()
fun flushNoteParagraph() {
if (currentNoteParagraph.isNotEmpty()) {
notes.add(currentNoteParagraph.toString().trim())
currentNoteParagraph = StringBuilder()
}
}
fun flushSection() { fun flushSection() {
if (currentType != null) { if (currentType != null) {
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList())) sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
@@ -37,6 +48,27 @@ object ChordProParser {
for (rawLine in lines) { for (rawLine in lines) {
val line = rawLine.trimEnd() val line = rawLine.trimEnd()
// Inside a notes block: collect lines as paragraphs
if (inNotesBlock) {
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
if (inner == "end_of_notes" || inner == "eon") {
flushNoteParagraph()
inNotesBlock = false
continue
}
}
if (line.isBlank()) {
flushNoteParagraph()
} else {
if (currentNoteParagraph.isNotEmpty()) {
currentNoteParagraph.append(" ")
}
currentNoteParagraph.append(line.trim())
}
continue
}
// Skip comments // Skip comments
if (line.trimStart().startsWith("#")) continue if (line.trimStart().startsWith("#")) continue
@@ -97,6 +129,14 @@ object ChordProParser {
"end_of_repeat", "eor" -> { "end_of_repeat", "eor" -> {
flushSection() flushSection()
} }
"start_of_notes", "son" -> {
inNotesBlock = true
}
"end_of_notes", "eon" -> {
// Should have been handled in the notes block above
flushNoteParagraph()
inNotesBlock = false
}
"chorus" -> { "chorus" -> {
flushSection() flushSection()
sections.add(SongSection(type = SectionType.CHORUS)) sections.add(SongSection(type = SectionType.CHORUS))

View File

@@ -485,4 +485,96 @@ class ChordProParserTest {
line.segments[2].chord shouldBe "G" line.segments[2].chord shouldBe "G"
line.segments[2].text shouldBe "End" line.segments[2].text shouldBe "End"
} }
@Test
fun `parse notes block with multiple paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
First paragraph of the notes.
It continues on the next line.
Second paragraph with different content.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
song.notes[1] shouldBe "Second paragraph with different content."
}
@Test
fun `parse notes block with single paragraph`() {
val input = """
{title: Song}
{start_of_notes}
A single note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "A single note paragraph."
}
@Test
fun `parse notes block with short directives son eon`() {
val input = """
{title: Song}
{son}
Short form notes.
{eon}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "Short form notes."
}
@Test
fun `notes block and single note directives combine`() {
val input = """
{title: Song}
{note: Single line note}
{start_of_notes}
Block note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "Single line note"
song.notes[1] shouldBe "Block note paragraph."
}
@Test
fun `parse notes block with three paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
Paragraph one.
Paragraph two.
Paragraph three.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 3
song.notes[0] shouldBe "Paragraph one."
song.notes[1] shouldBe "Paragraph two."
song.notes[2] shouldBe "Paragraph three."
}
} }

View File

@@ -226,19 +226,28 @@ class PdfBookRenderer : BookRenderer {
y -= config.layout.verseSpacing / 0.3528f 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()) { if (pageIndex == 0 && song.notes.isNotEmpty()) {
y -= 4f y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size val metaSize = config.fonts.metadata.size
for (note in song.notes) { 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.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY) cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize) cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(note) cb.showText(wrappedLine)
cb.endText() cb.endText()
y -= metaSize * 1.5f y -= noteLineHeight
}
// Add paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
y -= noteLineHeight * 0.3f
}
} }
} }