diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9b8e06b..8bf9ba5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -53,6 +53,7 @@ kotlin { implementation(libs.koin.androidx.compose) implementation("com.google.zxing:core:3.5.4") implementation("com.github.billthefarmer:mididriver:1.25") + implementation("com.tom-roush:pdfbox-android:2.0.27.0") } commonMain.dependencies { // implementation(compose.components.resources) @@ -86,6 +87,7 @@ kotlin { implementation(libs.kotlinx.coroutinesSwing) implementation("com.google.zxing:core:3.5.4") implementation("com.google.zxing:javase:3.5.4") + implementation("org.apache.pdfbox:pdfbox:3.0.7") } } targets.all { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c0af1cb..5002896 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt index cb9cdb7..d4b335b 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt @@ -1,6 +1,7 @@ package mg.dot.feufaro import android.app.Application +import com.tom_roush.pdfbox.android.PDFBoxResourceLoader import org.koin.core.context.GlobalContext.startKoin import mg.dot.feufaro.di.commonModule // Importez votre module commun import org.koin.android.ext.koin.androidContext @@ -12,6 +13,7 @@ import mg.dot.feufaro.di.platformModule class AndroidApp: Application() { override fun onCreate() { super.onCreate() + PDFBoxResourceLoader.init(this) startKoin { // Log Koin messages (INFO pour le développement, ERROR pour la production) androidLogger(Level.INFO) diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt index 8bdbe5c..5f58e4c 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt @@ -1,7 +1,11 @@ package mg.dot.feufaro import android.content.Context +import android.content.Intent import android.net.Uri +import android.os.Environment +import android.widget.Toast +import androidx.core.content.FileProvider import feufaro.composeapp.generated.resources.Res import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -62,10 +66,10 @@ class AndroidFileRepository(private val context: Context) : FileRepository { } } - override fun getFileName(shortName: String): String { + /*override fun getFileName(shortName: String): String { val folder = context.getExternalFilesDir(null) return "$folder/$shortName" - } + }*/ // Dans androidMain / AndroidFileRepository.kt /*override suspend fun readFileBytes(filePath: String): ByteArray = withContext(Dispatchers.IO) { val folder = context.getExternalFilesDir(null) @@ -73,5 +77,59 @@ class AndroidFileRepository(private val context: Context) : FileRepository { if (!file.exists()) throw IOException("File not found: ${file.absolutePath}") file.readBytes() }*/ + override suspend fun saveLocalFile(filePath: String, data: ByteArray) { + val cleanPath = if (filePath.contains("/storage/emulated/0/")) { + filePath.substring(filePath.indexOf("/storage/emulated/0/")) + } else { + filePath + } + val file = File(cleanPath) + try { + file.parentFile?.mkdirs() + file.writeBytes(data) + println("Fichier sauvegardé avec succès dans : ${file.absolutePath}") + openPdfFile(context = context, filePath = file.absolutePath) + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } + override fun getFileName(shortName: String): String { + return File(context.filesDir, shortName).absolutePath + } + + override fun pickSavePath(defaultName: String): String? { + val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + return File(downloadDir, defaultName).absolutePath + } + suspend fun openPdfFile(context: Context, filePath: String) { + val file = File(filePath) + if (!file.exists()) return + + // 1. Transformer le File en Uri sécurisée + val uri: Uri = FileProvider.getUriForFile( + context, + "mg.dot.feufaro.fileprovider", + file + ) + + // uvrir un PDF + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Important ! + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + withContext(Dispatchers.Main) { + Toast.makeText(context.applicationContext, "Fichier sauvegardé avec succès", Toast.LENGTH_LONG).show() + } + context.startActivity(intent) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context.applicationContext, "Aucun lecteur PDF trouvé", Toast.LENGTH_SHORT).show() + } + } + } } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt index 088df3a..cff8293 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt @@ -1,19 +1,26 @@ package mg.dot.feufaro +import android.Manifest import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.core.app.ActivityCompat import androidx.core.view.WindowCompat -import android.view.WindowManager import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import org.koin.androidx.compose.KoinAndroidContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 1 + ) enableEdgeToEdge() super.onCreate(savedInstanceState) setFilePickerActivity(this) diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt new file mode 100644 index 0000000..eaaf3c1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -0,0 +1,1041 @@ +package mg.dot.feufaro.pdf + +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.PDPage +import com.tom_roush.pdfbox.pdmodel.PDPageContentStream +import com.tom_roush.pdfbox.pdmodel.common.PDRectangle +import com.tom_roush.pdfbox.pdmodel.font.PDType0Font +import com.tom_roush.pdfbox.util.Matrix +import feufaro.composeapp.generated.resources.Res +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mg.dot.feufaro.FileRepository +import mg.dot.feufaro.data.GridTUOData +import mg.dot.feufaro.solfa.TimeUnitObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +private data class PdfColor(val r: Float, val g: Float, val b: Float) + +private val COLOR_BLACK = PdfColor(0f, 0f, 0f) +private val COLOR_SOPRANO = PdfColor(180 / 255f, 0f, 0f) +private val COLOR_ALTO = PdfColor(0f, 120 / 255f, 0f) +private val COLOR_TENOR = PdfColor(0f, 0f, 180 / 255f) +private val COLOR_BASSE = PdfColor(0f, 100 / 255f, 120 / 255f) + +private fun PDPageContentStream.setNonStrokingPdfColor(c: PdfColor) = + setNonStrokingColor(c.r, c.g, c.b) + +// ── Sanitize ────────────────────────────────────────────────────────────────── +private fun String.sanitize(): String = this + .replace('\u2015', '—') + .replace('\u2014', '—') + .replace('\u2013', '—') + .replace('―', '-') + .replace('\u00A0', ' ') + .trim() + .filter { it.code in 32..126 || it.code > 160 } + +// ── Mesures texte ───────────────────────────────────────────────────────────── +private fun textWidth(font: PDType0Font, fontSize: Float, text: String): Float = + try { font.getStringWidth(text.sanitize()) / 1000f * fontSize } + catch (e: Exception) { fontSize * text.length * 0.6f } + + + +private val REGEX_PREFIX_DIGITS2 = Regex("(\\d+)\\.") +private val REGEX_CLEAN = Regex("^(\\d+\\.)+") +private val REGEX_HAS_LYRIC = Regex("[a-zA-Z0-9]") +private val REGEX_CLEAN_GLOBAL = Regex("\\d+\\.") + +private fun cleanSyllable(raw: String): String = + raw.replace(REGEX_CLEAN_GLOBAL, "").replace(Regex("\\s+"), " ").trim() + +private fun prefixIsOnlyOne(rawSyl: String): Boolean { + val digits = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1] }.toList() + return digits.size == 1 && digits[0] == "1" +} + +private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): Float { + var total = 0f + text.forEach { ch -> + total += try { font.getStringWidth(ch.toString().sanitize()) / 1000f * fontSize } + catch (e: Exception) { 0f } + } + return total +} + +private fun bestColWidthPt( + tuoList: List, + font: org.apache.pdfbox.pdmodel.font.PDType0Font, + noteFontSize: Float, + lyricFont: org.apache.pdfbox.pdmodel.font.PDType0Font, + lyricFontSize: Float +): Float { + var maxNoteW = 0f + var maxLyrW = 0f + + tuoList.forEach { tuo -> + tuo.noteAsMultiString() + .split("\n") + .forEach { line -> + val w = measureNoteWidth(font, noteFontSize, line) + if (w > maxNoteW) { + maxNoteW = w + } + } + //lyrics + (1..2).forEach { index -> + tuo.lyricsAsMultiString(index) + .split("\n") + .forEach { line -> + val trimmedLine = cleanSyllable(line) + val x = measureNoteWidth(font, lyricFontSize, trimmedLine) + if (x > maxLyrW) { + maxLyrW = x + } + } + } + } + val minRequiredByLyrics = maxLyrW * 0.65f + + return maxOf(maxNoteW + 2f, minRequiredByLyrics) +} + +private fun spacedSyllable( + currentSyl: String, + allTemps: List>, + sIdx: Int, + lineIdx: Int, + colWidthPt: Float, + font: PDType0Font, + fontSize: Float +): Pair { + val clean = currentSyl.sanitize() + if (clean.isEmpty()) return "" to 0f + + val currentW = textWidth(font, fontSize, clean) + + // Si ça rentre, on ne décale pas (aligné à gauche sur la note) + if (currentW <= colWidthPt) return clean to 0f + + val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: "" + val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else "" + + val nextEmpty = nextSyl.sanitize().trim().isEmpty() + val prevEmpty = prevSyl.sanitize().trim().isEmpty() + + return when { + // Si place libre à droite : on reste à gauche, on déborde à droite + nextEmpty -> clean to 0f + + // Si place libre à gauche : on se colle à droite, on déborde à gauche + prevEmpty -> clean to (colWidthPt - currentW) + + // Si bloqué ou libre des deux côtés : on centre pour équilibrer + else -> clean to (colWidthPt - currentW) / 2f + } +} + +// ── getVirtualLineIndex ─────────────────────────────────────────────────────── +fun getVirtualLineIndex(realLi: Int, spacings: List): Int { + var sum = realLi + for (i in 0 until realLi) sum += spacings[i] + return sum +} + +// ── drawHairPinForTUO ───────────────────────────────────────────────────────── +private fun drawHairPinForTUO( + cs: PDPageContentStream, + tuo: TimeUnitObject, + gridColumnCount: Int, + colX: Float, colWidth: Float, + markerY: Float, + noteLineH: Float +) { + val hairPinSymbol = tuo.hasHairPin() ?: return + + if (hairPinSymbol != '=' && TimeUnitObject.lastHairPinSymbol == null) { + TimeUnitObject.startHairPin(hairPinSymbol, tuo.numBlock) + return + } + + if (hairPinSymbol == '=' && TimeUnitObject.lastHairPinSymbol != null) { + val hairPinStart = TimeUnitObject.lastHairPinStart + val lastHairPinSymbol = TimeUnitObject.lastHairPinSymbol!! + + val hairPinStartLine = (hairPinStart - 1) / gridColumnCount + val hairPinEndLine = (tuo.numBlock - 1) / gridColumnCount + if (hairPinStartLine != hairPinEndLine) { TimeUnitObject.endHairPin(); return } + + val colsDelta = (tuo.numBlock - hairPinStart).toFloat() + val xStart = if (lastHairPinSymbol == '>') colX - colWidth * colsDelta else colX + colWidth / 2f + val xEnd = if (lastHairPinSymbol == '>') colX + colWidth / 2f else colX - colWidth * colsDelta + + val markerMidY = markerY + noteLineH * 0.4f + cs.setStrokingColor(0.2f, 0.2f, 0.2f); cs.setLineWidth(1f) + + cs.moveTo(xStart, markerMidY + noteLineH * 0.6f); cs.lineTo(xEnd, markerMidY); cs.stroke() + cs.moveTo(xStart, markerMidY - noteLineH * 0.4f); cs.lineTo(xEnd, markerMidY); cs.stroke() + + TimeUnitObject.endHairPin() + } +} + +// ── drawTUOUnderlines ───────────────────────────────────────────────────────── +private fun drawTUOUnderlines( + cs: PDPageContentStream, + tuo: TimeUnitObject, + font: PDType0Font, + fontSize: Float, + colX: Float, + y: Float, + noteLineH: Float, + colWidth: Float, + customSpacing: List +) { + val multiLineText = tuo.noteAsMultiString() + val noteLines = multiLineText.split("\n") + val separatorLength = if (tuo.sep0 in listOf(":", "!")) 1 else 0 + val leftMarginUnderline = when (tuo.sep0) { + "!" -> 4f; ":" -> 3f; "|" -> 4f; "/" -> 4f; else -> 0f + } + + tuo.annotations().forEach { ta -> + val voiceLineIndex = ta.voiceNumber - 1 + val vLi = getVirtualLineIndex(voiceLineIndex, customSpacing) + val underlineY = y - (vLi * noteLineH) - 3f + val lineText = noteLines.getOrNull(voiceLineIndex) ?: return@forEach + + ta.underlineSpec.forEach { us -> + var xStart = if (us.x > -1) colX + measureNoteWidth(font, fontSize, lineText.take(us.x)) else colX + if (us.isNewParen) xStart += leftMarginUnderline + + val xEnd = if (us.y > -1) { + val endIdx = (us.y + separatorLength + 1).coerceAtMost(lineText.length) + colX + measureNoteWidth(font, fontSize, lineText.take(endIdx)) + } else colX + colWidth + + if (xEnd > xStart) { + cs.setStrokingColor(0.3f, 0.3f, 0.3f); cs.setLineWidth(0.9f) + cs.moveTo(xStart, underlineY); cs.lineTo(xEnd, underlineY); cs.stroke() + } + } + } +} + +// ── drawNoteWithModulation ──────────────────────────────────────────────────── +private fun drawNoteWithModulation( + cs: PDPageContentStream, font: PDType0Font, fontSize: Float, + text: String, x: Float, y: Float +) { + val parts = text.split(">") + var curX = x + parts.forEachIndexed { index, part -> + if (part.isEmpty()) return@forEachIndexed + val isExposant = index == 1 + val currentSize = if (isExposant) fontSize * 0.7f else fontSize + val currentY = if (isExposant) y + (fontSize * 0.45f) else y + cs.beginText() + cs.setTextMatrix(Matrix.getTranslateInstance(curX, currentY)) + cs.setFont(font, currentSize) + cs.showText(part) + cs.endText() + curX += textWidth(font, currentSize, part) + 0.5f + } +} + +// ── drawNoteWithSubscript ───────────────────────────────────────────────────── +private fun drawNoteWithSubscript( + cs: PDPageContentStream, font: PDType0Font, fontSize: Float, + text: String, x: Float, y: Float +) { + var curX = x + var i = 0 + while (i < text.length) { + val str = text[i].toString() + cs.beginText() + cs.setFont(font, fontSize) + cs.newLineAtOffset(curX, y) + cs.showText(str.sanitize()) + cs.endText() + curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize + i++ + } +} + +// ── drawHeader ──────────────────────────────────────────────────────────────── +private fun drawHeader( + cs: PDPageContentStream, + noteFont: PDType0Font, lyricFont: PDType0Font?, + pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float, + songTitle: String, songAuthor: String, songRhythm: String, + songComposer: String, songKey: String, measure: String, + stanza: Int, nbStanza: Int +) { + val titleFont = lyricFont ?: noteFont + val normalFont = lyricFont ?: noteFont + val smallFont = noteFont + + fun tw(f: PDType0Font, sz: Float, t: String) = + try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f } + + // Ligne 1 — Titre centré + val titleSize = 15f + val titleW = tw(titleFont, titleSize, songTitle) + cs.beginText(); cs.setFont(titleFont, titleSize) + cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, pageHeight - 48f) + cs.showText(songTitle.sanitize()); cs.endText() + + // Ligne 2 — Auteur | Compositeur + val line2Y = pageHeight - 64f; val sz2 = 9f + cs.beginText(); cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText() + + val composerW = tw(normalFont, sz2, songComposer) + cs.beginText(); cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y) + cs.showText(songComposer.sanitize()); cs.endText() + + // Ligne 3 — Key | Measure | Rythme + val line3Y = pageHeight - 76f; val sz3 = 8f + val keyTxt = "Dô dia ${songKey.sanitize()}" + cs.beginText(); cs.setFont(smallFont, sz3) + cs.newLineAtOffset(marginX, line3Y); cs.showText(keyTxt); cs.endText() + + val keyW = tw(smallFont, sz3, keyTxt) + cs.beginText(); cs.setFont(smallFont, sz3) + cs.newLineAtOffset(marginX + keyW + 10f, line3Y) + cs.showText(measure.sanitize()); cs.endText() + + val rhythmW = tw(normalFont, sz3, songRhythm.sanitize()) + cs.beginText(); cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y) + cs.showText(songRhythm.sanitize()); cs.endText() + + // Ligne de séparation + cs.setStrokingColor(0.6f, 0.6f, 0.6f); cs.setLineWidth(0.4f) + cs.moveTo(marginX, line3Y - 5f); cs.lineTo(marginX + usableWidth, line3Y - 5f); cs.stroke() +} + +// ── actual fun ──────────────────────────────────────────────────────────────── +actual fun rememberPdfExportAction( + scope: CoroutineScope, + fileRepository: FileRepository, + gridData: GridTUOData, + songTitle: String, + measure: String, + stanza: Int, + nbStanza: Int, + songKey: String, + songAut: String, + songComp: String, + songRythm: String +): () -> Unit { + return { + scope.launch(Dispatchers.IO) { + val (pdfBytes, computedFileName) = generatePdfToBytes( + gridData, songTitle, measure, stanza, + nbStanza, songKey, songAut, songComp, songRythm + ) + val chosenPath = withContext(Dispatchers.Main) { + fileRepository.pickSavePath(computedFileName) + } + if (chosenPath != null) { + fileRepository.saveLocalFile(chosenPath, pdfBytes) + } + } + } +} + +// ── generatePdfToBytes ──────────────────────────────────────────────────────── +private suspend fun generatePdfToBytes( + gridData: GridTUOData, + songTitle: String, + measure: String, + stanza: Int, + nbStanza: Int, + songKey: String, + songAut: String, + songComp: String, + songRythm: String +): Pair { + + val tuoList = gridData.tuoList.drop(1) + + val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.") + + fun getVoicesFromPrefix(syl: String): List = + REGEX_PREFIX_DIGITS.findAll(syl).map { it.groupValues[1].toInt() }.toList() + + val markerHeight = 16f + val noteFontSize = 12.5f + val lyricFontSize = 13.25f + val noteLineH = 15f + val lyricLineH = 13f + val extraSylLineH = 14f + val adjustedMargin = 14f + val interRowGap = 16f + val marginX = 40f + val marginY = 50f + val headerH = 55f + val pageWidth = PDRectangle.A4.width + val pageHeight = PDRectangle.A4.height + val usableWidth = pageWidth - 2 * marginX + + // ── Polices ──────────────────────────────────────────────────────────────── + val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf") + val lyricFontBytes = Res.readBytes("files/LinLibertine_R.ttf") + val markerItalicBoldFontBytes = Res.readBytes("files/PT Serif Bold Italic.ttf") + val markerBoldFontBytes = Res.readBytes("files/PTSerif-Bold.ttf") + val emmentalerFontBytes = Res.readBytes("files/emmentaler-20.ttf") + + PDDocument().use { doc -> + + val customFont = PDType0Font.load(doc, ByteArrayInputStream(noteFontBytes), false) + val lyricFont = PDType0Font.load(doc, ByteArrayInputStream(lyricFontBytes), false) + val markerItalicFont = PDType0Font.load(doc, ByteArrayInputStream(markerItalicBoldFontBytes), false) + val markerBoldFont = PDType0Font.load(doc, ByteArrayInputStream(markerBoldFontBytes), false) + val markerEmmetFont = PDType0Font.load(doc, ByteArrayInputStream(emmentalerFontBytes), false) + + // ── Largeur optimale de colonne ──────────────────────────────────────── + val bestColW = bestColWidthPt(tuoList, customFont, noteFontSize, lyricFont, lyricFontSize) + val colCount = ((usableWidth / bestColW).toInt()).coerceAtLeast(1) + val colWidth = bestColW + + val maxSylsSize = tuoList.maxOfOrNull { tuo -> + (1..nbStanza).maxOfOrNull { sz -> tuo.getSingleSyllable(sz).size } ?: 1 + } ?: 1 + + // ── Fonctions de dessin locales ──────────────────────────────────────── + fun newPage(): PDPageContentStream { + val page = PDPage(PDRectangle.A4) + doc.addPage(page) + return PDPageContentStream(doc, page) + } + + fun drawCurlyBrace(cs: PDPageContentStream, x: Float, top: Float, bottom: Float) { + val height = top - bottom + val braceW = (height * 0.12f).coerceIn(8f, 22f) + cs.saveGraphicsState() + cs.transform(Matrix(braceW / 10f, 0f, 0f, -(height / 90f), x - braceW, top)) + cs.setNonStrokingColor(0f, 0f, 0f) + cs.moveTo(2.5255237f, 42.511266f) + cs.curveTo(2.9018235f, 41.543703f, 2.9183988f, 40.479268f, 2.9295801f, 39.441167f) + cs.curveTo(3.0257633f, 30.51126f, 3.0823959f, 21.580072f, 2.947325f, 12.650669f) + cs.curveTo(2.9231886f, 11.055039f, 2.8933167f, 9.4523308f, 3.1035398f, 7.8704257f) + cs.curveTo(3.3137629f, 6.2885207f, 3.7758163f, 4.7150177f, 4.6625942f, 3.3882765f) + cs.curveTo(5.8680949f, 1.5846823f, 7.8548731f, 0.32344155f, 10f, 0f) + cs.curveTo(9.1651831f, 0.77722338f, 8.4802709f, 1.7148791f, 7.9937845f, 2.746541f) + cs.curveTo(6.9576584f, 4.9437899f, 6.8533308f, 7.4514513f, 6.8235522f, 9.8805609f) + cs.curveTo(6.7206706f, 18.272857f, 7.2905092f, 26.672179f, 6.8823909f, 35.055177f) + cs.curveTo(6.8167718f, 36.403033f, 6.7250316f, 37.755886f, 6.4343209f, 39.073653f) + cs.curveTo(6.1436102f, 40.39142f, 5.6454801f, 41.680731f, 4.8313656f, 42.756947f) + cs.curveTo(4.0971435f, 43.727549f, 3.1128448f, 44.507326f, 2f, 45f) + cs.curveTo(3.2050792f, 45.603993f, 4.2555169f, 46.513477f, 5.0257355f, 47.619726f) + cs.curveTo(5.9928965f, 49.008695f, 6.5043972f, 50.673120f, 6.7431543f, 52.348661f) + cs.curveTo(6.9819115f, 54.024202f, 6.9612618f, 55.724436f, 6.9478472f, 57.416849f) + cs.curveTo(6.8820492f, 65.718083f, 7.0018422f, 74.020174f, 6.9072398f, 82.321127f) + cs.curveTo(6.8879888f, 84.004806f, 6.8714598f, 85.749672f, 7.5929830f, 87.289708f) + cs.curveTo(8.0528414f, 88.422141f, 8.9242492f, 89.387018f, 10f, 90f) + cs.curveTo(8.1813551f, 89.702562f, 6.4820251f, 88.725349f, 5.3102118f, 87.3031f) + cs.curveTo(4.2259102f, 85.987066f, 3.606374f, 84.337657f, 3.2912749f, 82.661838f) + cs.curveTo(2.9761757f, 80.986019f, 2.9488582f, 79.270938f, 2.9359838f, 77.565801f) + cs.curveTo(2.869984f, 68.824508f, 3.1582519f, 60.082204f, 3.0067424f, 51.341975f) + cs.curveTo(2.9840763f, 50.034421f, 2.9431715f, 48.687654f, 2.4144109f, 47.491567f) + cs.curveTo(1.9369295f, 46.411476f, 1.0645415f, 45.51121f, 0f, 45f) + cs.curveTo(1.1412417f, 44.575325f, 2.0841488f, 43.646153f, 2.5255237f, 42.511266f) + cs.closePath(); cs.fill() + cs.restoreGraphicsState() + } + + // ── Fermata (dessin vectoriel — remplace le rendu glyphe Emmentaler) ── + fun drawFermata(cs: PDPageContentStream, x: Float, y: Float, width: Float = 10f, height: Float = 12f) { + cs.setStrokingColor(0f, 0f, 0f); cs.setNonStrokingColor(0f, 0f, 0f) + val circleRadius = width * 0.22f + val circleX = x + width / 2 + val circleY = y + height * 0.25f + val k = 0.5522847498f + cs.moveTo(circleX + circleRadius, circleY) + cs.curveTo(circleX + circleRadius, circleY + circleRadius * k, circleX + circleRadius * k, circleY + circleRadius, circleX, circleY + circleRadius) + cs.curveTo(circleX - circleRadius * k, circleY + circleRadius, circleX - circleRadius, circleY + circleRadius * k, circleX - circleRadius, circleY) + cs.curveTo(circleX - circleRadius, circleY - circleRadius * k, circleX - circleRadius * k, circleY - circleRadius, circleX, circleY - circleRadius) + cs.curveTo(circleX + circleRadius * k, circleY - circleRadius, circleX + circleRadius, circleY - circleRadius * k, circleX + circleRadius, circleY) + cs.fill() + cs.setLineWidth(1.5f) + val arcStartX = x + width * 0.2f; val arcStartY = y + height * 0.55f + val arcPeakX = x + width / 2; val arcPeakY = y + height * 0.85f + val arcEndX = x + width * 0.8f; val arcEndY = y + height * 0.55f + cs.moveTo(arcStartX, arcStartY) + cs.curveTo(arcStartX + width * 0.15f, arcPeakY, arcPeakX - width * 0.15f, arcPeakY, arcPeakX, arcPeakY) + cs.curveTo(arcPeakX + width * 0.15f, arcPeakY, arcEndX - width * 0.15f, arcStartY, arcEndX, arcStartY) + cs.stroke() + } + + // ── drawMarkers ──────────────────────────────────────────────────────── + // NOTE : pas de TrueTypeFont / emmentalerTTF ici sur Android. + // La fermata (𝄐) est dessinée via drawFermata() vectoriel. + fun drawMarkers( + cs: PDPageContentStream, + tuo: TimeUnitObject, + colX: Float, colWidth: Float, y: Float, + boldFont: PDType0Font, italicBoldFont: PDType0Font, emmentalerFont: PDType0Font, + fontSize: Float + ) { + val markerText = tuo.pTemplate.markerToString() + val hairPinSymbol = tuo.hasHairPin() + val isTriolet = tuo.isTriolet() + + val textMarkerOffset = 6f + val markerY = y - markerHeight / 2 + 4f + textMarkerOffset + + // Point d'orgue → drawFermata (pas de GeneralPath sur Android) + if (markerText.contains("𝄐") || tuo.pTemplate.template.contains("𝄐")) { + drawFermata(cs, colX + colWidth / 2 - 5f, markerY - 2f, 10f, 12f) + } + + // Marqueur texte + if (markerText.isNotBlank() && !markerText.contains("𝄐")) { + val dsRegex = Regex("""D\.?S\.?""") + val dcRegex = Regex("""D\.?C\.?""") + cs.beginText() + when { + markerText.matches(Regex("^\\s*(ppp|pp|p|mp|mf|f|ff|fff)\\s*$")) -> + cs.setFont(emmentalerFont, fontSize + 10f) + markerText.contains(dsRegex) || markerText.contains(dcRegex) || + markerText.contains("$") || markerText.contains("Do dia", false) -> + cs.setFont(boldFont, fontSize) + else -> cs.setFont(italicBoldFont, fontSize) + } + cs.newLineAtOffset(colX + 2f, markerY) + cs.showText(markerText) + cs.endText() + } + + // Triolet + if (isTriolet) { + val arcX = colX + colWidth * 0.125f + val arcWidth = colWidth * 0.75f + val arcHeight = 7f + cs.setLineWidth(1.2f) + cs.moveTo(arcX, markerY + 2f) + cs.curveTo( + arcX + arcWidth * 0.25f, markerY + arcHeight, + arcX + arcWidth * 0.75f, markerY + arcHeight, + arcX + arcWidth, markerY + arcHeight + ) + cs.stroke() + cs.beginText() + cs.setFont(boldFont, fontSize * 0.7f) + cs.newLineAtOffset(arcX + arcWidth * 0.45f, markerY + arcHeight * 0.85f) + cs.showText("3") + cs.endText() + } + } + + // ── Génération des pages ─────────────────────────────────────────────── + var cs = newPage() + var y = pageHeight - marginY - headerH + + drawHeader( + cs, customFont, lyricFont, + pageWidth, pageHeight, marginX, usableWidth, + songTitle = songTitle, + songAuthor = songAut, + songRhythm = songRythm, + songComposer = songComp, + songKey = songKey, + measure = measure, + stanza = stanza, + nbStanza = nbStanza + ) + + val rows = tuoList.chunked(colCount) + + rows.forEachIndexed { rowIdx, rowTuos -> + val maxNoteLines = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1 + val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0 + + val rowHasMarkers = rowTuos.any { tuo -> + tuo.pTemplate.markerToString().isNotBlank() || tuo.isTriolet() || tuo.hasHairPin() != null + } + + val noteBlockH = maxNoteLines * noteLineH + val nbStanzasToShow = if (maxSylsSize >= 2) 1 else if (nbStanza >= 2) 2 else 1 + val lyricBlockH = maxLyricLines * lyricLineH * nbStanzasToShow + val rowH = (if (rowHasMarkers) markerHeight else 0f) + noteBlockH + lyricBlockH + 10f * nbStanzasToShow + 8f + + if (y - rowH < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY } + + val notesStartY = if (rowHasMarkers) y - markerHeight else y + + val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex() + + fun singingVoiceIndices(tuo: TimeUnitObject): List = + tuo.noteAsMultiString().split("\n").mapIndexedNotNull { li, line -> + val clean = line.trim() + val sings = clean.isNotBlank() + && !clean.all { it == '-' || it.isWhitespace() } + && clean != "—" && clean != "–" + && pitchPattern.containsMatchIn(clean) + if (sings) li else null + } + + val spacings = MutableList(4) { 0 } + + rowTuos.forEach { tuo -> + val singing = singingVoiceIndices(tuo) + val rawSyls = tuo.getSingleSyllable(stanza) + val rawSyl0 = rawSyls.getOrNull(0) ?: "" + val cleanSyl0 = rawSyl0.replace(REGEX_CLEAN, "").trim() + val syl0HasRealText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) && + !cleanSyl0.all { it == '―' || it == '-' || it == '—' } + val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + val uneAutreVoixPrendLeSoprano = (1..4).any { vIdx -> + val s = rawSyls.getOrNull(vIdx) ?: "" + s.contains("1.") && REGEX_HAS_LYRIC.containsMatchIn(s.replace(REGEX_CLEAN, "")) + } + val aDuTexteAuNiveauSoprano = !syl0EstVraimentVide || uneAutreVoixPrendLeSoprano + + (1..4).forEach { vIdx -> + val rawSyl = rawSyls.getOrNull(vIdx) ?: "" + val cleanContent = cleanSyllable(rawSyl) + val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanContent) && + !cleanContent.all { it == '―' || it == '-' || it == '—' } + + if (rawSyl.isNotBlank() && hasActualText) { + val prefixInts = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + if (prefixInts.isNotEmpty()) { + val hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4)) + val hasDuoSA = prefixInts.size == 2 && prefixInts.containsAll(listOf(1, 2)) + val hasDuoTB = prefixInts.size == 2 && prefixInts.containsAll(listOf(3, 4)) + val isUnisson = prefixInts.containsAll(listOf(1, 2, 3, 4)) + + if (prefixInts.contains(2) && prefixInts.contains(3)) { + spacings[2] = 1; spacings[1] = 0 + } + if (isUnisson) { /* rien */ } + else if (hasTrioATB) spacings[3] = 1 + else if (hasDuoSA) spacings[1] = 1 + else if (hasDuoTB) { if (syl0HasRealText) spacings[1] = 1; spacings[3] = 1 } + else { + prefixInts.forEach { voiceNum -> + when (voiceNum) { + 1 -> if (syl0HasRealText) spacings[0] = 1 + 2 -> if (!prefixInts.contains(1)) spacings[1] = 1 + 3 -> if (!prefixInts.contains(2)) spacings[2] = 1 + 4 -> if (!prefixInts.contains(3)) spacings[3] = 1 + } + } + } + } + } + } + } + + val customSpacings = spacings.toList() + + // ---- 1. Marqueurs ---- + if (rowHasMarkers) { + rowTuos.forEachIndexed { colIdx, tuo -> + val x = marginX + colIdx * colWidth + // ⚠️ Pas de emmentalerTTF sur Android → signature sans ce param + drawMarkers(cs, tuo, x, colWidth, y, markerBoldFont, markerItalicFont, markerEmmetFont, noteFontSize) + drawHairPinForTUO(cs, tuo, colCount, x, colWidth, y, noteLineH) + } + } + + // ---- 2. Accolade & étiquettes SATB ---- + val braceTop = notesStartY + noteLineH * 0.85f + val lastVoiceIdx = (maxNoteLines - 1).coerceIn(0, 3) + val totalVirtualSlots = getVirtualLineIndex(lastVoiceIdx, customSpacings) + 1 + val braceBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH + drawCurlyBrace(cs, marginX, braceTop, braceBottom) + + val lineBottom = braceBottom + val voiceLabels = listOf("S", "A", "T", "B") + val labelFont = lyricFont + val labelSize = noteFontSize * 0.85f + val braceW = ((braceTop - braceBottom) * 0.12f).coerceIn(8f, 22f) + val labelX = marginX - braceW - 3f + + voiceLabels.take(maxNoteLines).forEachIndexed { li, label -> + val vLi = getVirtualLineIndex(li, customSpacings) + val labelY = notesStartY - (vLi * noteLineH) + // Couleur par voix (sans java.awt.Color) + val labelColor = when (label) { + "S" -> COLOR_SOPRANO + "A" -> COLOR_ALTO + "T" -> COLOR_TENOR + "B" -> COLOR_BASSE + else -> COLOR_BLACK + } + cs.saveGraphicsState() + cs.beginText() + cs.setFont(labelFont, labelSize) + cs.setNonStrokingPdfColor(labelColor) + cs.newLineAtOffset(labelX - textWidth(labelFont, labelSize, label), labelY) + cs.showText(label) + cs.endText() + cs.restoreGraphicsState() + } + + // ---- 3. Notes ---- + val lineTop = notesStartY + noteLineH * 0.85f + + rowTuos.forEachIndexed { colIdx, tuo -> + val x = marginX + colIdx * colWidth + val sepW = textWidth(customFont, noteFontSize, "|") + val noteX = when (tuo.sep0) { + "/", "|" -> { + cs.setStrokingColor(0f, 0f, 0f); cs.setLineWidth(0.8f) + cs.moveTo(x + sepW * 0.2f, lineTop); cs.lineTo(x + sepW * 0.2f, lineBottom); cs.stroke() + if (tuo.sep0 == "/") { + cs.moveTo(x + sepW * 0.7f, lineTop); cs.lineTo(x + sepW * 0.7f, lineBottom); cs.stroke() + } + x + sepW + 2f + } + else -> x + } + + val noteLines = tuo.noteAsMultiString().split("\n") + noteLines.forEachIndexed { li, line -> + val vLi = getVirtualLineIndex(li, customSpacings) + val currentY = notesStartY - (vLi * noteLineH) + if (line.isNotBlank()) { + if (line.contains(">")) drawNoteWithModulation(cs, customFont, noteFontSize, line, noteX, currentY) + else drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, currentY) + } + } + + drawTUOUnderlines(cs, tuo, customFont, noteFontSize, x, notesStartY, noteLineH, colWidth, customSpacings) + } + + // ---- 4. Paroles ---- + val lastVoiceIndex = (maxNoteLines - 1).coerceIn(0, 3) + val lastVirtualIndex = getVirtualLineIndex(lastVoiceIndex, customSpacings) + val totalAreaSlots = lastVirtualIndex + customSpacings[lastVoiceIndex] + val finalBraceBottom = notesStartY - (totalAreaSlots * noteLineH) + + val stanzasToShow = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1) + var lowestYOfThisRow = finalBraceBottom + + stanzasToShow.forEachIndexed { stanzaIdx, currentStanza -> + val nbOverrideLines = customSpacings.sum() + val yLyric = finalBraceBottom - adjustedMargin - (nbOverrideLines * noteLineH * 0.2f) + val yLyricOffset = stanzaIdx * (lyricLineH * 1.1f) + val currentYLyric = yLyric - yLyricOffset + + val allTemps: List> = rowTuos.map { it.getSingleSyllable(currentStanza) } + val maxLinesThisStanza = allTemps.maxOfOrNull { it.size } ?: 1 + + // Numéro de stanza + val stanzaNumTxt = "$currentStanza." + val stanzaNumSize = lyricFontSize * 0.85f + val stanzaNumW = try { lyricFont.getStringWidth(stanzaNumTxt) / 1000f * stanzaNumSize } catch (e: Exception) { 0f } + cs.beginText(); cs.setFont(lyricFont, stanzaNumSize) + cs.setNonStrokingColor(0f, 0f, 0f) // ← remplace setNonStrokingColor(Color.BLACK) + cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric) + cs.showText(stanzaNumTxt); cs.endText() + + // ── drawInOverrideSlot (closure locale) ─────────────────────── + fun drawInOverrideSlot( + cs: PDPageContentStream, + targetIdx: Int, + cleanSyl: String, + sIdx: Int, + lineIdx: Int, + allTemps: List>, + noteX: Float, + colWidth: Float, + customFont: PDType0Font, + lyricFontSize: Float, + notesStartY: Float, + noteLineH: Float, + customSpacings: List, + isDirectY: Boolean = false + ) { + // Couleur par voix (sans java.awt.Color) + val lyricColor = when (targetIdx) { + -1, 0 -> COLOR_SOPRANO + 1 -> COLOR_ALTO + 2 -> COLOR_TENOR + 3 -> COLOR_BASSE + else -> COLOR_BLACK + } + val finalY = if (isDirectY) notesStartY + else { + val vLi = getVirtualLineIndex(targetIdx, customSpacings) + notesStartY - ((vLi + 1) * noteLineH) + 2f + } + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX + + cs.saveGraphicsState() + cs.beginText() + cs.setFont(customFont, lyricFontSize * 0.9f) + cs.setNonStrokingPdfColor(lyricColor) // ← remplace setNonStrokingColor(lyricColor) + cs.newLineAtOffset(lyricX, finalY) + cs.showText(spacedSyl.sanitize()) + cs.endText() + cs.restoreGraphicsState() + } + + // Syllabes + allTemps.forEachIndexed { sIdx, syllables -> + val x = marginX + sIdx * colWidth + val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x + val filledSlots = mutableSetOf() + val isStandardLayout = customSpacings.all { it == 0 } + + syllables.forEachIndexed { lineIdx, rawSyl -> + if (lineIdx == 0) { + val (spacedSyl, offsetX) = spacedSyllable(rawSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX + cs.beginText() + cs.setFont(lyricFont, lyricFontSize) + cs.newLineAtOffset(lyricX, currentYLyric) + cs.showText(spacedSyl.sanitize()) + cs.endText() + } else { + val isAllZeroSpacing = customSpacings.all { it == 0 } + + // 1. D'abord, on vérifie si c'est un Solo Soprano "1." même si spacing est à 0 + val prefixes = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + val rawSyl0 = syllables.getOrNull(0) ?: "" + val cleanSyl0 = cleanSyllable(rawSyl0) + val cleanSyl = cleanSyllable(rawSyl) + + val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + + val isAllZeroSpacing = customSpacings.all { it == 0 } + + if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) { + // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0) + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/ + + cs.beginText() + cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize) + cs.newLineAtOffset(lyricX, currentYLyric) + cs.showText(spacedSyl.sanitize()) + cs.endText() + + } else if (isAllZeroSpacing) { + val cleanSyl = cleanSyllable(rawSyl) + if (cleanSyl.isNotBlank()) { + val thisLineY = currentYLyric - lineIdx * extraSylLineH + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX + cs.beginText() + cs.setFont(lyricFont, lyricFontSize) + cs.setNonStrokingColor(0f, 0f, 0f) // ← remplace Color.BLACK + cs.newLineAtOffset(lyricX, thisLineY) + cs.showText(spacedSyl.sanitize()) + cs.endText() + } + } else { + val prefixes = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + val cleanSyl = cleanSyllable(rawSyl) + val rawSyl0 = syllables.getOrNull(0) ?: "" + val cleanSyl0 = cleanSyllable(rawSyl0) + val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) && + !cleanSyl.all { it == '―' || it == '-' } + + if (prefixes.isNotEmpty() && hasActualText) { + prefixes.forEach { voiceNum -> + val targetIdx = voiceNum - 1 + if (voiceNum == 1) { + if (lignePrincipaleEstLibre && !filledSlots.contains(-1)) { + drawInOverrideSlot(cs, -1, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth, + customFont, lyricFontSize, currentYLyric, noteLineH, customSpacings, isDirectY = true) + filledSlots.add(-1) + } else if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) { + drawInOverrideSlot(cs, 0, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth, + customFont, lyricFontSize, notesStartY - yLyricOffset, noteLineH, customSpacings) + filledSlots.add(0) + } + } else { + if (customSpacings.getOrElse(targetIdx) { 0 } == 1 && !filledSlots.contains(targetIdx)) { + drawInOverrideSlot(cs, targetIdx, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth, + customFont, lyricFontSize, notesStartY, noteLineH, customSpacings) + filledSlots.add(targetIdx) + } + } + } + } + } + } + } + } + + val isAllZeroSpacing = customSpacings.all { it == 0 } + val maxExtraLines = if (isAllZeroSpacing) { + allTemps.maxOfOrNull { sylList -> + sylList.count { rawSyl -> + val clean = cleanSyllable(rawSyl) + REGEX_HAS_LYRIC.containsMatchIn(clean) && !clean.all { it == '―' || it == '-' || it == '—' } + }.coerceAtLeast(1) + } ?: 1 + } else 1 + + val bottomOfCurrentStanza = currentYLyric - ((maxExtraLines - 1) * extraSylLineH) - 4f + if (bottomOfCurrentStanza < lowestYOfThisRow) lowestYOfThisRow = bottomOfCurrentStanza + } + + y = lowestYOfThisRow - interRowGap + } + + // ── Stanzas restantes en texte ───────────────────────────────────────── + val stanzasDisplayed = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1) + val stanzasRemaining = (1..nbStanza).filter { it !in stanzasDisplayed } + + if (stanzasRemaining.isNotEmpty()) { + val textFont = lyricFont + val textSize = lyricFontSize + val lineH = lyricLineH + val stanzaGapY = 12f + + y -= 7f + if (y < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY } + cs.setStrokingColor(0.5f, 0.5f, 0.5f); cs.setLineWidth(0.3f) + cs.moveTo(marginX, y + 5f); cs.lineTo(marginX + usableWidth, y + 5f); cs.stroke() + y -= 6f + + fun buildStanzaText(sz: Int): List { + val normalLyrics = mutableListOf>() + val allDcLyrics = mutableListOf>() + + tuoList.forEachIndexed { tuoIdx, tuo -> + val syls = tuo.getSingleSyllable(sz) + val normal = syls.getOrNull(0) ?: "" + val dcPart = syls.drop(1).joinToString(" ") { it.trim() } + if (normal.isNotEmpty()) normalLyrics.add(tuoIdx to normal) + if (dcPart.isNotEmpty()) allDcLyrics.add(tuoIdx to dcPart) + } + + val result = StringBuilder() + var dcIndex = 0 + var inDcBlock = false + + tuoList.forEachIndexed { tuoIdx, tuo -> + val syls = tuo.getSingleSyllable(sz) + val normal = syls.getOrNull(0)?.trim() ?: "" + val dcPart = syls.drop(1).joinToString(" ") { it.trim() } + if (normal.isNotEmpty()) result.append(normal).append(" ") + if (dcPart.isNotEmpty()) result.append("[$dcPart] ") + val marker = tuo.pTemplate.markerToString() + if (marker.contains("DC") && !inDcBlock && allDcLyrics.isNotEmpty()) { + result.append("(") + while (dcIndex < allDcLyrics.size) { + val (dcIdx, dcText) = allDcLyrics[dcIndex] + if (dcIdx > tuoIdx) break + result.append(dcText).append(" "); dcIndex++ + } + result.append(")"); inDcBlock = false + } + } + return listOf(result.toString().trim()) + } + + fun wrapLine(line: String, maxW: Float): List { + if (line.isBlank()) return listOf(line) + val totalW = try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f } + if (totalW <= maxW) return listOf(line) + val parts = mutableListOf() + val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line) + while (matcher.find()) parts.add(matcher.group()) + val result = mutableListOf() + var current = "" + parts.forEach { part -> + val candidate = current + part + val w = try { textFont.getStringWidth(candidate.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f } + if (w <= maxW || current.isEmpty()) current = candidate + else { result.add(current.replace(Regex("\\s+$"), "")); current = part } + } + if (current.isNotEmpty()) result.add(current) + return result + } + + fun naturalWidth(lines: List): Float = + lines.maxOfOrNull { line -> + try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f } + } ?: 0f + + fun stanzaHeight(wrappedLines: List): Float = lineH + wrappedLines.size * lineH + stanzaGapY + + val availH = y - marginY + val allStanzaRaw = stanzasRemaining.map { sz -> sz to buildStanzaText(sz) } + val maxNatW = allStanzaRaw.maxOfOrNull { (_, lines) -> naturalWidth(lines) } ?: (usableWidth / 2) + + val optimalCols: Int + val colW: Float + val innerW: Float + + when (stanzasRemaining.size) { + 1 -> { optimalCols = 1; colW = usableWidth; innerW = colW - 10f } + 2 -> { optimalCols = 2; colW = usableWidth / 2f; innerW = colW - 10f } + else -> { + val best = (1..3).lastOrNull { nCols -> + val cWTest = usableWidth / nCols + val innerWTest = cWTest - 10f + val wrapped = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerWTest) } } + val colHeights = Array(nCols) { 0f } + wrapped.forEachIndexed { idx, (_, wLines) -> colHeights[idx % nCols] += stanzaHeight(wLines) } + colHeights.all { it <= availH } + } ?: 3 + optimalCols = best; colW = usableWidth / optimalCols; innerW = colW - 10f + } + } + + val stanzaWrapped = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerW) } } + + data class ColItem(val sz: Int, val lines: List) + val rows2 = stanzaWrapped.chunked(optimalCols) + var rowStartY = y + + rows2.forEach { rowItems -> + val rowH2 = rowItems.maxOfOrNull { (_, wLines) -> stanzaHeight(wLines) } ?: 0f + if (rowStartY - rowH2 < marginY) { cs.close(); cs = newPage(); rowStartY = pageHeight - marginY } + + rowItems.forEachIndexed { colIdx, (sz, wrappedLines) -> + val colX = marginX + colIdx * colW + var colY = rowStartY + cs.beginText(); cs.setFont(textFont, textSize * 0.9f) + cs.newLineAtOffset(colX, colY); cs.showText("$sz."); cs.endText() + colY -= lineH + wrappedLines.forEach { line -> + cs.beginText(); cs.setFont(textFont, textSize) + cs.newLineAtOffset(colX + 10f, colY); cs.showText(line.sanitize()); cs.endText() + colY -= lineH + } + } + rowStartY -= (rowH2 + stanzaGapY) + } + y = rowStartY + } + + cs.close() + + val cleanTitle = songTitle.replace(" ", "_") + val totalUsableHeight = pageHeight - (2 * marginY) + val remainingHeight = (y - marginY).coerceAtLeast(0f) + val percentageUsed = 100f - (remainingHeight / totalUsableHeight) * 100 + val percentageRemain = 100f - percentageUsed + val rStr = String.format("%.2f", percentageRemain).replace(",", ".") + val uStr = String.format("%.2f", percentageUsed).replace(",", ".") + val computedFileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf" + + val outputStream = ByteArrayOutputStream() + doc.save(outputStream) + doc.close() + + return Pair(outputStream.toByteArray(), computedFileName) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf b/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf new file mode 100644 index 0000000..ab15444 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf differ diff --git a/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf b/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf new file mode 100644 index 0000000..da5a256 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf differ diff --git a/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf b/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf new file mode 100644 index 0000000..36d47eb Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf b/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf new file mode 100644 index 0000000..f87c0f1 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf b/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf new file mode 100644 index 0000000..778067a Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf differ diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt index 94e9757..358081a 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt @@ -15,8 +15,10 @@ interface FileRepository { suspend fun getOutputStream(filePath: String): OutputStream //Lire le dernier dossier d'importation suspend fun saveFile(filePath: String, data: ByteArray) + suspend fun saveLocalFile(filePath: String, data: ByteArray) fun getFileName(shortName: String) : String // suspend fun readFileBytes(filePath: String): ByteArray + fun pickSavePath(defaultName: String): String? } // This is just a regular class that implements the common 'FileRepository' interface. diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt index 6f81e6e..b83f5de 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt @@ -15,7 +15,7 @@ val commonModule = module { single { SharedScreenModel(fileRepository = get()) } single { Solfa(get(), get()) } - single { SolfaScreenModel(get()) } + single { SolfaScreenModel(get(), get()) } single { MusicXML(get()) } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt new file mode 100644 index 0000000..093dd36 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -0,0 +1,19 @@ +package mg.dot.feufaro.pdf + +import kotlinx.coroutines.CoroutineScope +import mg.dot.feufaro.FileRepository +import mg.dot.feufaro.data.GridTUOData + +expect fun rememberPdfExportAction( + scope: CoroutineScope, + fileRepository: FileRepository, + gridData: GridTUOData, + songTitle: String, + measure: String, + stanza: Int, + nbStanza: Int, + songKey: String, + songAut: String, + songComp: String, + songRythm: String +): () -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt index d83bef9..8e772ca 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -746,6 +746,15 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val override = O.getOrElse(overrideNumber) { null } override?.forEachIndexed { index, value -> val matchResult = Regex("v(\\d+):(.*)").find(value) + // 1. Extraire le numéro de voix (ex: "2") + val voiceNum = matchResult?.groupValues?.get(1) ?: "" + val prefix = if (voiceNum.isNotEmpty()) { + // .map transforme chaque caractère '3', '4' en "3.", "4." + // .joinToString("") les colle ensemble -> "3.4." + voiceNum.map { "$it." }.joinToString("") + } else { + "" + } val overrideString = when (smartLyricsType) { "E" -> smartELyrics(matchResult?.groupValues[2] ?: value) "Y" -> smartYLyrics(matchResult?.groupValues[2] ?: value) @@ -753,7 +762,11 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository } for (iN in (1..9)) { if (matchResult?.groupValues[1]?.contains(iN.digitToChar()) ?: false) { - val overrideSyllabus = overrideString.split(Regex("[_/]")).iterator() + val overrideSyllabus = overrideString.split(Regex("[_/]")) + .map { syllable -> + if (syllable.isNotBlank()) "$prefix$syllable" else syllable + } + .iterator() overrideIterator[iN] = overrideSyllabus } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt index b0e17eb..2eef60a 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -755,6 +755,12 @@ fun LazyVerticalGridTUO( horizontalArrangement = Arrangement.Start ) { val columnWidthDp = gridWidthDp / gridColumnCount + val REGEX_CLEAN_PREFIX = Regex("(\\d+\\.)+") + val cleanAllTemps = allTemps.map { column -> + column.map { syllable -> + syllable.replace(REGEX_CLEAN_PREFIX, "").trim() + } + } allTemps.forEachIndexed { syls_i, syllables -> Column( modifier = Modifier @@ -763,7 +769,9 @@ fun LazyVerticalGridTUO( val noteWidth = columnWidthDp val density = LocalDensity.current syllables.joinToString { " " } - syllables.mapIndexed { index, syl -> + + val cleanSyllables = cleanAllTemps[syls_i] + cleanSyllables.forEachIndexed { index, syl -> var textWidth by remember { mutableStateOf(0.dp) } @@ -777,7 +785,7 @@ fun LazyVerticalGridTUO( val isTooLong = textWidthDp > containerWidthDp val spacer = makeSpaceBetweenSyllables(textMeasurer, columnWidthDp) - val (dynamicSpaceSyl, alignmentText) = spacer(syl, allTemps, syls_i, index) + val (dynamicSpaceSyl, alignmentText) = spacer(syl, cleanAllTemps, syls_i, index) Text( text = dynamicSpaceSyl, modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index 4951f23..59c2e46 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mg.dot.feufaro.data.getDrawerItems +import mg.dot.feufaro.pdf.rememberPdfExportAction import mg.dot.feufaro.solfa.Solfa import mg.dot.feufaro.viewmodel.SolfaScreenModel @@ -47,6 +49,11 @@ fun MainScreenWithDrawer( val filteredSongs by sharedScreenModel.filteredSongs.collectAsState() val songKey = sharedScreenModel.songKey.collectAsState().value val measure = sharedScreenModel.measure.collectAsState().value + val stanza = sharedScreenModel.stanza.collectAsState().value + val nbStanzas = sharedScreenModel.nbStanzas.collectAsState().value + val songAuthor = sharedScreenModel.songAuthor.collectAsState().value + val songComposer = sharedScreenModel.songComposer.collectAsState().value + val songRhythm = sharedScreenModel.songRhythm.collectAsState().value val scrollState = rememberScrollState() val currentActivePath = Solfa.currentFile @@ -103,6 +110,20 @@ LaunchedEffect(isPlay, isPos) { } ) }, content = { + val scope = rememberCoroutineScope() + val pdfExportAction: () -> Unit = rememberPdfExportAction( + scope = scope, + fileRepository = solfaScreenModel.fileRepository, + gridData = sharedScreenModel.currentGridData, + songTitle = songTitle, + measure = measure, + stanza = stanza, + nbStanza = nbStanzas, + songKey = songKey, + songAut = songAuthor, + songComp = songComposer, + songRythm = songRhythm + ) Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( modifier = Modifier.height(55.dp).windowInsetsPadding(WindowInsets.statusBars), title = { @@ -176,7 +197,9 @@ LaunchedEffect(isPlay, isPos) { exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 } ) { FloatingActionButton( - onClick = {}, modifier = Modifier.alpha(0.45f) + onClick = { + pdfExportAction() + }, modifier = Modifier.alpha(0.45f) ) { Icon( imageVector = Icons.Filled.Print, @@ -243,12 +266,12 @@ LaunchedEffect(isPlay, isPos) { delay(100) sharedScreenModel.setDragging(false) } - println("DrawerUI:335: mihetsika $newPos") + //println("DrawerUI:335: mihetsika $newPos") }, mediaPlayer = player, onVolumeChange = { newVolume -> sharedScreenModel.setVolume(newVolume) - println("Changement volume $newVolume -l $volumelevel") +// println("Changement volume $newVolume -l $volumelevel") }, onVoiceVolumeChange = { index, volume -> player?.updateVoiceVolume(index, volume) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index 53b26a6..fc1eb0e 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import mg.dot.feufaro.FileRepository import mg.dot.feufaro.data.DrawerItem +import mg.dot.feufaro.data.GridTUOData import mg.dot.feufaro.data.getDrawerItems import mg.dot.feufaro.solfa.TimeUnitObject import mg.dot.feufaro.midi.FMediaPlayer @@ -163,6 +164,12 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode private val _activeIndex = MutableStateFlow(-1) val activeIndex: StateFlow = _activeIndex.asStateFlow() + val currentGridData: GridTUOData + get() = GridTUOData( + tuoList = _tuoList.value, + measure = _measure.value, + stanza = _stanza.value + ) fun updateActiveIndex(currentPosMs: Long) { val currentPosMicros = currentPosMs * 1000 val index = _tuoTimestamps.value.indexOfLast { it <= currentPosMicros } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt index 37e2c4a..2734baf 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt @@ -1,9 +1,11 @@ package mg.dot.feufaro.viewmodel import cafe.adriel.voyager.core.model.ScreenModel +import mg.dot.feufaro.FileRepository import mg.dot.feufaro.solfa.Solfa class SolfaScreenModel( + val fileRepository: FileRepository, private val solfa: Solfa ) : ScreenModel { init {} diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt index 9717243..95569e2 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt @@ -4,6 +4,8 @@ import feufaro.composeapp.generated.resources.Res import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import mg.dot.feufaro.FileRepository +import java.awt.FileDialog +import java.awt.Frame import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -46,9 +48,29 @@ class DesktopFileRepository : FileRepository { // IMPORTS AND IMPLEMENTS THE com val file = File("$userHome/$filePath") file.writeBytes(data) // Extension Kotlin très efficace } + override suspend fun saveLocalFile(filePath: String, data: ByteArray) { + val file = if (filePath.startsWith("/")) { + File(filePath) // chemin absolu → utilisé directement + } else { + File(System.getProperty("user.home"), filePath) // chemin relatif → préfixé + } + file.parentFile?.mkdirs() // crée les dossiers parents si nécessaire + file.writeBytes(data) // Extension Kotlin très efficace + } override fun getFileName(shortName: String): String { val userHome = System.getProperty("user.home") return "$userHome/$shortName" } + + override fun pickSavePath(defaultName: String): String? { + val dialog = FileDialog(null as Frame?, "Exporter en PDF", FileDialog.SAVE) + dialog.directory = System.getProperty("user.home") + dialog.file = defaultName + dialog.isVisible = true + + return if (dialog.directory != null && dialog.file != null) { + File(dialog.directory, dialog.file).absolutePath + } else null + } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt new file mode 100644 index 0000000..5ded061 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -0,0 +1,1752 @@ +package mg.dot.feufaro.pdf + +import feufaro.composeapp.generated.resources.Res +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mg.dot.feufaro.FileRepository +import mg.dot.feufaro.data.GridTUOData +import mg.dot.feufaro.solfa.TimeUnitObject +import org.apache.fontbox.ttf.TTFParser +import org.apache.fontbox.ttf.TrueTypeFont +import org.apache.pdfbox.io.RandomAccessReadBuffer +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.font.PDType0Font +import org.apache.pdfbox.util.Matrix +import java.awt.Color +import java.awt.geom.GeneralPath +import java.awt.geom.PathIterator +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File as JFile + + +private fun String.sanitize(): String = this + .replace('\u2015', '—') + .replace('\u2014', '—') + .replace('\u2013', '—') + .replace('―', '-') // ← tiret sol-fa → tiret court +// .replace('\u2022', '.') // ← bullet → point + .replace('\u00A0', ' ') +// .replace('₁', '1').replace('₂', '2').replace('₃', '3') +// .replace('¹', '\'').replace('²', '"').replace('³', '"') + .trim() + .filter { it.code in 32..126 || it.code > 160 } + +/** Mesure la largeur d'un texte en points PDFBox */ +private fun textWidth(font: PDType0Font, fontSize: Float, text: String): Float { + return try { + font.getStringWidth(text.sanitize()) / 1000f * fontSize + } catch (e: Exception) { fontSize * text.length * 0.6f } +} + +private val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.") +private val REGEX_CLEAN = Regex("^(\\d+\\.)+") +private val REGEX_HAS_LYRIC = Regex("[a-zA-Z0-9]") +// On enlève "chiffre." n'importe où dans le texte +private val REGEX_CLEAN_GLOBAL = Regex("\\d+\\.") + +// Fonction de nettoyage robuste +private fun cleanSyllable(raw: String): String { + return raw.replace(REGEX_CLEAN_GLOBAL, "") // Enlève "4." + .replace(Regex("\\s+"), " ") // Remplace les espaces multiples par un seul + .trim() // Enlève les espaces aux extrémités +} +// si le préfixe contient UNIQUEMENT le chiffre "1" +private fun prefixIsOnlyOne(rawSyl: String): Boolean { + val digits = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1] }.toList() + return digits.size == 1 && digits[0] == "1" +} + +private fun bestColWidthPt( + tuoList: List, + font: PDType0Font, + noteFontSize: Float, + lyricFont: PDType0Font, + lyricFontSize: Float +): Float { + var maxNoteW = 0f + var maxLyrW = 0f + + tuoList.forEach { tuo -> + tuo.noteAsMultiString() + .split("\n") + .forEach { line -> + val w = measureNoteWidth(font, noteFontSize, line) + if (w > maxNoteW) { + maxNoteW = w + // println("Not: [$line] = [$maxNoteW]") + } + } + //lyrics + (1..2).forEach { index -> + tuo.lyricsAsMultiString(index) + .split("\n") + .forEach { line -> + val trimmedLine = cleanSyllable(line) + val x = measureNoteWidth(font, lyricFontSize, trimmedLine) + if (x > maxLyrW) { + maxLyrW = x + // println("Lyr: [$trimmedLine] = [$maxLyrW]") + } + } + } + } + //La colonne fait la taille de la note + marge, + val minRequiredByLyrics = maxLyrW * 0.65f + // println("La taille est = [${maxOf(maxNoteW + 2f, minRequiredByLyrics)}]") + return maxOf(maxNoteW + 2f, minRequiredByLyrics) +} + +/** Mesure la largeur exacte comme drawNoteWithSubscript */ +private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): Float { + var totalW = 0f + text.forEach { ch -> + totalW += try { + font.getStringWidth(ch.toString().sanitize()) / 1000f * fontSize + } catch (e: Exception) { 0f } + } + return totalW +} +/** Equivalent de makeSpaceBetweenSyllables pour PDFBox */ +private fun spacedSyllable( + currentSyl: String, + allTemps: List>, + sIdx: Int, + lineIdx: Int, + colWidthPt: Float, + font: PDType0Font, + fontSize: Float +): Pair { // Pair + /* + ANCIENNEMENT BASÉE SUR LARGEUR LYRICS LES BESTCOLWIDTH + if (currentSyl.isEmpty()) return "" to true + val spaceW = textWidth(font, fontSize, " ").coerceAtLeast(1f) + val currentW = textWidth(font, fontSize, currentSyl) + val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: "" + val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else "" + val excessPt = currentW - colWidthPt + val isExcess = excessPt > 0 + val neededSpaces = if (isExcess) ((excessPt / spaceW).toInt() + 1).coerceAtMost(20) else 0 + val totalSpacesPossible = (colWidthPt / spaceW).toInt() + val paddingNeeded = totalSpacesPossible - (currentSyl.length + 4).coerceIn(0, 20) + + return when { + !isExcess && paddingNeeded > 0 -> + (currentSyl + " ".repeat(paddingNeeded)) to true + isExcess && nextSyl.length < prevSyl.length -> + (" ".repeat(neededSpaces) + currentSyl) to false + isExcess && nextSyl.length >= prevSyl.length -> + (currentSyl + " ".repeat(neededSpaces)) to true + else -> currentSyl to true + }*/ + /*val clean = currentSyl.sanitize() + if (clean.isEmpty()) return "" to true + + val currentW = textWidth(font, fontSize, clean) + + // Si la syllabe est plus large que la colonne + if (currentW > colWidthPt) { + // On vérifie la syllabe suivante pour voir si on a de la place à droite + val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: "" + + // Si la suivante est vide, on peut déborder à droite sans crainte + if (nextSyl.isEmpty()) return clean to true + + // Sinon, on centre la syllabe sur la colonne pour équilibrer le débordement + // à gauche et à droite (ce qui réduit les collisions) + return clean to false // false ici signifiera "Centrer" dans notre logique de dessin + } + + return clean to true*/ + + val clean = currentSyl.sanitize() + if (clean.isEmpty()) return "" to 0f + + val currentW = textWidth(font, fontSize, clean) + + // Si ça rentre, on ne décale pas (aligné à gauche sur la note) + if (currentW <= colWidthPt) return clean to 0f + + val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: "" + val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else "" + + val nextEmpty = nextSyl.sanitize().trim().isEmpty() + val prevEmpty = prevSyl.sanitize().trim().isEmpty() + + return when { + // Si place libre à droite : on reste à gauche, on déborde à droite + nextEmpty -> clean to 0f + + // Si place libre à gauche : on se colle à droite, on déborde à gauche + prevEmpty -> clean to (colWidthPt - currentW) + + // Si bloqué ou libre des deux côtés : on centre pour équilibrer + else -> clean to (colWidthPt - currentW) / 2f + } +} + + + +private fun drawEmmentalerGlyphByName( + cs: PDPageContentStream, + ttf: TrueTypeFont, + glyphName: String, + x: Float, + y: Float, + size: Float = 14f +) { + try { + val glyph = ttf.getGlyph() + val glyphTable = ttf.glyph ?: return + val nameTable = ttf.naming + + // Cherche l'index du glyphe par nom + val glyphOrder = ttf.postScript?.glyphNames ?: return + val gid = glyphOrder.indexOfFirst { it == glyphName } + if (gid < 0) { + println("⚠️ Glyphe '$glyphName' non trouvé dans la font") + return + } + + val glyphData = glyphTable.getGlyph(gid) ?: run { + println("⚠️ GlyphData null pour GID=$gid ('$glyphName')") + return + } + + val path: GeneralPath = glyphData.getPath() + + // 3. Récupère les métriques pour la mise à l'échelle + val unitsPerEm = ttf.unitsPerEm.toFloat() + val scale = size / unitsPerEm + + // 4. Dessine le path dans le PDF + cs.saveGraphicsState() + cs.transform(Matrix(scale, 0f, 0f, -scale, x, y)) + cs.setNonStrokingColor(0f, 0f, 0f) + + val pi = path.getPathIterator(null) + val coords = FloatArray(6) + while (!pi.isDone) { + when (pi.currentSegment(coords)) { + PathIterator.SEG_MOVETO -> cs.moveTo(coords[0], coords[1]) + PathIterator.SEG_LINETO -> cs.lineTo(coords[0], coords[1]) + PathIterator.SEG_CUBICTO -> cs.curveTo( + coords[0], coords[1], + coords[2], coords[3], + coords[4], coords[5] + ) + PathIterator.SEG_QUADTO -> { + // Quadratique → cubique (PDF ne supporte que cubique) + val cpX = coords[0]; val cpY = coords[1] + val endX = coords[2]; val endY = coords[3] + // Point courant inconnu ici → approximation directe + cs.curveTo(cpX, cpY, cpX, cpY, endX, endY) + } + PathIterator.SEG_CLOSE -> cs.closePath() + } + pi.next() + } + cs.fill() + cs.restoreGraphicsState() + + } catch (e: Exception) { + println("⚠️ Erreur glyphe '$glyphName': ${e.message}") + } +} + +fun getVirtualLineIndex(realLi: Int, spacings: List): Int { + var sum = realLi // La ligne de la note elle-même + for (i in 0 until realLi) { + sum += spacings[i] // On ajoute les espaces des voix au-dessus + } + return sum +} + + +private fun drawHairPinForTUO( + cs: PDPageContentStream, + tuo: TimeUnitObject, + gridColumnCount: Int, + colX: Float, colWidth: Float, + markerY: Float, // Y de la ligne des marqueurs (y dans ton code) + noteLineH: Float +) { + val hairPinSymbol = tuo.hasHairPin() ?: return + + if (hairPinSymbol != '=' && TimeUnitObject.lastHairPinSymbol == null) { + TimeUnitObject.startHairPin(hairPinSymbol, tuo.numBlock) + return + } + + if (hairPinSymbol == '=' && TimeUnitObject.lastHairPinSymbol != null) { + val hairPinStart = TimeUnitObject.lastHairPinStart + val lastHairPinSymbol = TimeUnitObject.lastHairPinSymbol!! + + // Même calcul que Compose (ligne de départ/arrivée) + val hairPinStartLine = (hairPinStart - 1) / gridColumnCount + val hairPinEndLine = (tuo.numBlock - 1) / gridColumnCount + + // Optionnel : limite à une ligne (comme ton code commenté en Compose) + if (hairPinStartLine != hairPinEndLine) { + TimeUnitObject.endHairPin() + return + } + + // X calculés comme en Compose (colsDelta = nb colonnes traversées) + val colsDelta = (tuo.numBlock - hairPinStart).toFloat() + val xStart = if (lastHairPinSymbol == '>') { + colX - colWidth * colsDelta // decrescendo : gauche → centre + } else { + colX + colWidth / 2f // crescendo : centre → gauche + } + val xEnd = if (lastHairPinSymbol == '>') { + colX + colWidth / 2f + } else { + colX - colWidth * colsDelta + } + + // Y : au milieu de la zone marqueurs (haut + bas) + val markerMidY = markerY + noteLineH * 0.4f + val lineWidth = 1f + cs.setStrokingColor(0.2f, 0.2f, 0.2f) + cs.setLineWidth(lineWidth) + + // Branche haute (oblique vers le centre) + cs.moveTo(xStart, markerMidY + noteLineH * 0.6f) + cs.lineTo(xEnd, markerMidY) + cs.stroke() + + // Branche basse + cs.moveTo(xStart, markerMidY - noteLineH * 0.4f) + cs.lineTo(xEnd, markerMidY) + cs.stroke() + + TimeUnitObject.endHairPin() + } +} + + +private fun drawTUOUnderlines( + cs: PDPageContentStream, + tuo: TimeUnitObject, + font: PDType0Font, + fontSize: Float, + colX: Float, // X de début de colonne (avant séparateur, comme le Box dans Compose) + y: Float, // Y baseline de la voix 1 + noteLineH: Float, + colWidth: Float, + customSpacing: List +) { + // noteAsMultiString() contient le préfixe séparateur (":", "|") pour ":" et "!" + val multiLineText = tuo.noteAsMultiString() + val noteLines = multiLineText.split("\n") + + // Identique à Compose : separatorLength compense le char ":" ou "|" en début de ligne + val separatorLength = if (tuo.sep0 in listOf(":", "!")) 1 else 0 + + // leftMarginUnderline équivalent Compose (en pts ≈ dp dans ce contexte) + val leftMarginUnderline = when (tuo.sep0) { + "!" -> 4f + ":" -> 3f + "|" -> 4f + "/" -> 4f + else -> 0f + } + + tuo.annotations().forEach { ta -> + val voiceLineIndex = ta.voiceNumber - 1 // voiceNumber est 1-indexé comme dans Compose + // --- CORRECTION ICI --- + // On récupère l'index virtuel pour correspondre à la position réelle de la note + val vLi = getVirtualLineIndex(voiceLineIndex, customSpacing) + + // On calcule le Y en fonction de cet index virtuel + // On descend de 2f ou 3f sous la baseline pour ne pas toucher la lettre + val underlineY = y - (vLi * noteLineH) - 3f + + val lineText = noteLines.getOrNull(voiceLineIndex) ?: return@forEach + + ta.underlineSpec.forEach { us -> + // ── xStart ── (équivalent getCursorRect(lineGlobalStartOffset + us.x).left) + var xStart = if (us.x > -1) { + colX + measureNoteWidth(font, fontSize, lineText.take(us.x)) + } else { + colX + } + if (us.isNewParen) xStart += leftMarginUnderline + + // ── xEnd ── (équivalent getCursorRect(lineGlobalStartOffset + us.y + separatorLength + 1).right) + val xEnd = if (us.y > -1) { + val endIdx = (us.y + separatorLength + 1).coerceAtMost(lineText.length) + colX + measureNoteWidth(font, fontSize, lineText.take(endIdx)) + } else { + colX + colWidth // us.y == -1 → jusqu'au bord droit de la colonne + } + + // ── Y ── identique à Compose : voiceNumber * totalH / nbNotes → bas de la ligne de voix + // En PDFBox Y croît vers le haut donc on descend de 2f sous la baseline +// val underlineY = y - voiceLineIndex * noteLineH - 2f + + if (xEnd > xStart) { + cs.setStrokingColor(0.3f, 0.3f, 0.3f) + cs.setLineWidth(0.9f) + cs.moveTo(xStart, underlineY) + cs.lineTo(xEnd, underlineY) + cs.stroke() + } + } + } +} + +private fun drawNoteWithModulation( + cs: PDPageContentStream, + font: PDType0Font, + fontSize: Float, + text: String, + x: Float, + y: Float +) { + val parts = text.split(">") + var currentX = x + + parts.forEachIndexed { index, part -> + if (part.isEmpty()) return@forEachIndexed + + // RÈGLE : Si c'est avant le dernier segment, c'est un exposant + // Dans ">t>m", 't' est à l'index 1 (exposant), 'm' est à l'index 2 (normal) + // ATTENTION : selon votre logique, si c'est ">t>m", t=exposant, m=normal ? + // Ou bien tout ce qui suit le premier ">" est spécial ? + + val isExposant = index == 1 // Le premier élément après le '>' + + val currentSize = if (isExposant) fontSize * 0.7f else fontSize + val currentY = if (isExposant) y + (fontSize * 0.45f) else y + + cs.beginText() + cs.setTextMatrix(Matrix.getTranslateInstance(currentX, currentY)) + cs.setFont(font, currentSize) + cs.showText(part) + cs.endText() + + currentX += textWidth(font, currentSize, part) + 0.5f + } +} + +private fun drawNoteWithSubscript( + cs: PDPageContentStream, + font: PDType0Font, + fontSize: Float, + text: String, + x: Float, + y: Float +) { + var curX = x + var i = 0 + while (i < text.length) { + val ch = text[i] + val str = ch.toString() + cs.beginText() + cs.setFont(font, fontSize) + cs.newLineAtOffset(curX, y) + cs.showText(str.sanitize()) + cs.endText() + curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize +// } + i++ + } +} + +// ── Header +private fun drawHeader( + cs: PDPageContentStream, + noteFont: PDType0Font, + lyricFont: PDType0Font?, + pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float, + songTitle: String, songAuthor: String, songRhythm: String, + songComposer: String, songKey: String, measure: String, + stanza: Int, nbStanza: Int +) { + val titleFont = lyricFont ?: noteFont + val normalFont = lyricFont ?: noteFont + val smallFont = noteFont + + fun tw(f: PDType0Font, sz: Float, t: String) = + try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f } + + // Ligne 1 — Titre centré + val titleSize = 15f + val titleW = tw(titleFont, titleSize, songTitle) + cs.beginText(); cs.setFont(titleFont, titleSize) + cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, pageHeight - 48f) + cs.showText(songTitle.sanitize()); + cs.endText() + + // Ligne 2 — Author | Composer (droite) + val line2Y = pageHeight - 64f; val sz2 = 9f + cs.beginText(); cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText() + + val composerW = tw(normalFont, sz2, songComposer) + cs.beginText(); + cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y) + cs.showText(songComposer.sanitize()); + cs.endText() + + // Ligne 3 — Key | Measure | Rythm Stanza (droite) + val line3Y = pageHeight - 76f; val sz3 = 8f + val keyTxt = "Dô dia ${songKey.sanitize()}" + cs.beginText(); + cs.setFont(smallFont, sz3) + cs.newLineAtOffset(marginX, line3Y); cs.showText(keyTxt); + cs.endText() + + val keyW = tw(smallFont, sz3, keyTxt) + val measureTxt = "${measure.sanitize()}" + cs.beginText(); + cs.setFont(smallFont, sz3) + cs.newLineAtOffset(marginX + keyW + 10f, line3Y) + cs.showText("${measure.sanitize()}"); + cs.endText() + + val rhythmW = tw(normalFont, sz3, songRhythm.sanitize()) + cs.beginText(); cs.setFont(normalFont, sz2) + cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y) + cs.showText(songRhythm.sanitize()); cs.endText() + + + // Ligne de séparation + cs.setStrokingColor(0.6f, 0.6f, 0.6f); cs.setLineWidth(0.4f) + cs.moveTo(marginX, line3Y - 5f); cs.lineTo(marginX + usableWidth, line3Y - 5f); cs.stroke() +} + + + +actual fun rememberPdfExportAction( + scope: CoroutineScope, + fileRepository: FileRepository, + gridData: GridTUOData, + songTitle: String, + measure: String, + stanza: Int, + nbStanza: Int, + songKey: String, + songAut: String, + songComp: String, + songRythm: String +): () -> Unit { + return { + scope.launch(Dispatchers.IO) { + // 1. Générer le PDF en mémoire (ByteArray) + val (pdfBytes, computedFileName) = generatePdfToBytes( + gridData, songTitle, measure, stanza, + nbStanza, songKey, songAut, songComp, songRythm + ) + val chosenPath = withContext(Dispatchers.Main) { + fileRepository.pickSavePath(computedFileName) + } + if (chosenPath != null) { + fileRepository.saveLocalFile(chosenPath, pdfBytes) + } + } + } +} + +private suspend fun generatePdfToBytes( + gridData: GridTUOData, + songTitle: String, + measure: String, + stanza: Int, + nbStanza: Int, + songKey: String, + songAut: String, + songComp: String, + songRythm: String +): Pair { + val tuoList = gridData.tuoList.drop(1) + + // "2.4.Mai" -> [2, 4] + val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.") + + fun getVoicesFromPrefix(syl: String): List { + return REGEX_PREFIX_DIGITS.findAll(syl) + .map { it.groupValues[1].toInt() } + .toList() + } + + val markerHeight = 16f + val noteFontSize = 12f + val lyricFontSize = 12.5f + val noteLineH = 17f // espace entre 2 voix + val lyricLineH = 15f // espace entre lignes de lyrics + val extraSylLineH = 15f // espace entre syl0 et syl1+ (cas zero spacing) + val adjustedMargin = 14f // espace entre syl0 du bloc de notes + val interRowGap = 22f // espace entre deux portées + val marginX = 42.5f + val marginY = 42.5f + val headerH = 55f // hauteur réservée au header + val pageWidth = PDRectangle.A4.width + val pageHeight = PDRectangle.A4.height + val usableWidth = pageWidth - 2 * marginX + + // ── Polices ──────────────────────────────────────────────────── + val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf") + val lyricFontBytes = Res.readBytes("files/LinLibertine_R.ttf") + val markerItalicBoldFontBytes = Res.readBytes("files/PT Serif Bold Italic.ttf") + val markerBoldFontBytes = Res.readBytes("files/PTSerif-Bold.ttf") + val emmentalerFontBytes = Res.readBytes("files/emmentaler-20.ttf") + + PDDocument().use { doc -> + + // Charger les polices UNE FOIS dans le document + val customFont = PDType0Font.load(doc, ByteArrayInputStream(noteFontBytes), false) + val lyricFont = PDType0Font.load(doc, ByteArrayInputStream(lyricFontBytes), false) + val markerItalicFont = PDType0Font.load(doc, ByteArrayInputStream(markerItalicBoldFontBytes), false) + val markerBoldFont = PDType0Font.load(doc, ByteArrayInputStream(markerBoldFontBytes), false) + val markerEmmetFont = PDType0Font.load(doc, ByteArrayInputStream(emmentalerFontBytes), false) + + val emmentalerTTF: TrueTypeFont = TTFParser().parse( + RandomAccessReadBuffer(emmentalerFontBytes) + ) + // ── Calcul de la largeur optimale de colonne (= bestTUOWidth) ── + val bestColW = bestColWidthPt( + /*tuoList, customFont, + if (lyricFont is PDType0Font) lyricFont else customFont, + noteFontSize, lyricFontSize, stanza*/ + tuoList, + customFont, + noteFontSize, + lyricFont, + lyricFontSize + ) + val colCount = ((usableWidth / bestColW).toInt()).coerceAtLeast(1) + val colWidth = bestColW + + + val maxSylsSize = tuoList.maxOfOrNull { tuo -> // lyrics Alt + (1..nbStanza).maxOfOrNull { sz -> + tuo.getSingleSyllable(sz).size + } ?: 1 + } ?: 1 + + // ── Fonctions de dessin ──────────────────────────────────── + fun newPage(): PDPageContentStream { + val page = PDPage(PDRectangle.A4) + doc.addPage(page) + val cs = PDPageContentStream(doc, page) + + return cs + } + + fun drawCurlyBrace(cs: PDPageContentStream, x: Float, top: Float, bottom: Float) { + val height = top - bottom + val braceW = (height * 0.12f).coerceIn(8f, 22f) + + cs.saveGraphicsState() + cs.transform(Matrix( + braceW / 10f, // scaleX + 0f, + 0f, + -(height / 90f), // scaleY (négatif = flip Y) + x - braceW, // translateX + top // translateY + )) + cs.setNonStrokingColor(0f, 0f, 0f) + + cs.moveTo(2.5255237f, 42.511266f) + cs.curveTo(2.9018235f, 41.543703f, 2.9183988f, 40.479268f, 2.9295801f, 39.441167f) + cs.curveTo(3.0257633f, 30.51126f, 3.0823959f, 21.580072f, 2.947325f, 12.650669f) + cs.curveTo(2.9231886f, 11.055039f, 2.8933167f, 9.4523308f, 3.1035398f, 7.8704257f) + cs.curveTo(3.3137629f, 6.2885207f, 3.7758163f, 4.7150177f, 4.6625942f, 3.3882765f) + cs.curveTo(5.8680949f, 1.5846823f, 7.8548731f, 0.32344155f, 10f, 0f) + cs.curveTo(9.1651831f, 0.77722338f, 8.4802709f, 1.7148791f, 7.9937845f, 2.746541f) + cs.curveTo(6.9576584f, 4.9437899f, 6.8533308f, 7.4514513f, 6.8235522f, 9.8805609f) + cs.curveTo(6.7206706f, 18.272857f, 7.2905092f, 26.672179f, 6.8823909f, 35.055177f) + cs.curveTo(6.8167718f, 36.403033f, 6.7250316f, 37.755886f, 6.4343209f, 39.073653f) + cs.curveTo(6.1436102f, 40.39142f, 5.6454801f, 41.680731f, 4.8313656f, 42.756947f) + cs.curveTo(4.0971435f, 43.727549f, 3.1128448f, 44.507326f, 2f, 45f) + cs.curveTo(3.2050792f, 45.603993f, 4.2555169f, 46.513477f, 5.0257355f, 47.619726f) + cs.curveTo(5.9928965f, 49.008695f, 6.5043972f, 50.673120f, 6.7431543f, 52.348661f) + cs.curveTo(6.9819115f, 54.024202f, 6.9612618f, 55.724436f, 6.9478472f, 57.416849f) + cs.curveTo(6.8820492f, 65.718083f, 7.0018422f, 74.020174f, 6.9072398f, 82.321127f) + cs.curveTo(6.8879888f, 84.004806f, 6.8714598f, 85.749672f, 7.5929830f, 87.289708f) + cs.curveTo(8.0528414f, 88.422141f, 8.9242492f, 89.387018f, 10f, 90f) + cs.curveTo(8.1813551f, 89.702562f, 6.4820251f, 88.725349f, 5.3102118f, 87.3031f) + cs.curveTo(4.2259102f, 85.987066f, 3.606374f, 84.337657f, 3.2912749f, 82.661838f) + cs.curveTo(2.9761757f, 80.986019f, 2.9488582f, 79.270938f, 2.9359838f, 77.565801f) + cs.curveTo(2.869984f, 68.824508f, 3.1582519f, 60.082204f, 3.0067424f, 51.341975f) + cs.curveTo(2.9840763f, 50.034421f, 2.9431715f, 48.687654f, 2.4144109f, 47.491567f) + cs.curveTo(1.9369295f, 46.411476f, 1.0645415f, 45.51121f, 0f, 45f) + cs.curveTo(1.1412417f, 44.575325f, 2.0841488f, 43.646153f, 2.5255237f, 42.511266f) + cs.closePath() + cs.fill() + + cs.restoreGraphicsState() + } + + + fun drawFermata( + cs: PDPageContentStream, + x: Float, + y: Float, + width: Float = 10f, + height: Float = 12f + ) { + cs.setStrokingColor(0f, 0f, 0f) + cs.setNonStrokingColor(0f, 0f, 0f) + + // 1. Cercle (point en bas) - utiliser un cercle avec moveTo et curveTo + val circleRadius = width * 0.22f + val circleX = x + width / 2 + val circleY = y + height * 0.25f + + // Dessiner un cercle avec 4 courbes de Bézier + val k = 0.5522847498f // Constante pour approximer un cercle + val rx = circleRadius + val ry = circleRadius + + cs.moveTo(circleX + rx, circleY) + cs.curveTo(circleX + rx, circleY + ry * k, circleX + rx * k, circleY + ry, circleX, circleY + ry) + cs.curveTo(circleX - rx * k, circleY + ry, circleX - rx, circleY + ry * k, circleX - rx, circleY) + cs.curveTo(circleX - rx, circleY - ry * k, circleX - rx * k, circleY - ry, circleX, circleY - ry) + cs.curveTo(circleX + rx * k, circleY - ry, circleX + rx, circleY - ry * k, circleX + rx, circleY) + cs.fill() + + // 2. Arc supérieur (forme de sourcil) + cs.setLineWidth(1.5f) + + val arcStartX = x + width * 0.2f + val arcStartY = y + height * 0.55f + + val arcPeakX = x + width / 2 + val arcPeakY = y + height * 0.85f + + val arcEndX = x + width * 0.8f + val arcEndY = y + height * 0.55f + + // Dessiner l'arc avec curveTo (courbe de Bézier cubique) + cs.moveTo(arcStartX, arcStartY) + cs.curveTo( + arcStartX + width * 0.15f, arcPeakY, + arcPeakX - width * 0.15f, arcPeakY, + arcPeakX, arcPeakY + ) + cs.curveTo( + arcPeakX + width * 0.15f, arcPeakY, + arcEndX - width * 0.15f, arcStartY, + arcEndX, arcStartY + ) + cs.stroke() + } + + fun drawMarkers( + cs: PDPageContentStream, + tuo: TimeUnitObject, + colX: Float, + colWidth: Float, + y: Float, + boldFont: PDType0Font, + italicBoldFont: PDType0Font, + emmentalerFont: PDType0Font, + fontSize: Float, + emmentalerTTF: TrueTypeFont + ) { + val markerText = tuo.pTemplate.markerToString() + val hairPinSymbol = tuo.hasHairPin() + val isTriolet = tuo.isTriolet() + + // Position Y pour les marqueurs (au-dessus des notes) + val textMarkerOffset = 6f + val markerY = y - markerHeight /2 + 4f + textMarkerOffset + + // Point d'orgue + if (markerText.contains("𝄐") || tuo.pTemplate.template.contains("𝄐")) { + drawEmmentalerGlyphByName( + cs = cs, + ttf = emmentalerTTF, + glyphName = "scripts.dfermata", + x = colX + colWidth / 2 - 7f, + y = markerY - 2f, + size = 20f + ) + } + + // marqueur + if (markerText.isNotBlank() && !markerText.contains("𝄐")) { + val dsRegex = Regex("""D\.?S\.?""") + val dcRegex = Regex("""D\.?C\.?""") + + cs.beginText() + if(markerText.matches(Regex("^\\s*(ppp|pp|p|mp|mf|f|ff|fff)\\s*$"))) { + cs.setFont(emmentalerFont, fontSize+10f) + } + else if (markerText.contains(dsRegex) || + markerText.contains(dcRegex) || + markerText.contains("$") || + markerText.contains("Do dia", false)) { + cs.setFont(boldFont, fontSize) + } + else { + cs.setFont(italicBoldFont, fontSize) + } + cs.newLineAtOffset(colX + 2f, markerY) + cs.showText(markerText) + cs.endText() + } + + // 2. Dessiner le triolet (arc) + if (isTriolet) { + val arcX = colX + colWidth * 0.125f + val arcWidth = colWidth * 0.75f + val arcHeight = 7f + cs.setLineWidth(1.2f) + + // Dessiner un arc (approximation avec curveTo) + cs.moveTo(arcX, markerY + 2f) + cs.curveTo( + arcX + arcWidth * 0.25f, markerY + arcHeight, + arcX + arcWidth * 0.75f, markerY + arcHeight, + arcX + arcWidth, markerY + arcHeight + ) + cs.stroke() + + // Ajouter le "3" du triolet + cs.beginText() + cs.setFont(boldFont, fontSize * 0.7f) + cs.newLineAtOffset(arcX + arcWidth * 0.45f, markerY + arcHeight * 0.85f) + cs.showText("3") + cs.endText() + } + } + + + + + + // ── Génération des pages ─────────────────────────────────── + var cs = newPage() + var y = pageHeight - marginY - headerH + + drawHeader( + cs, customFont, lyricFont as? PDType0Font, + pageWidth, pageHeight, marginX, usableWidth, + songTitle = songTitle, + songAuthor = songAut, + songRhythm = songRythm, + songComposer = songComp, + songKey = songKey, + measure = measure, + stanza = stanza, + nbStanza = nbStanza + ) + + val rows = tuoList.chunked(colCount) + + rows.forEachIndexed { rowIdx, rowTuos -> + val maxNoteLines = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1 + val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0 + + val rowHasMarkers = rowTuos.any { tuo -> + tuo.pTemplate.markerToString().isNotBlank() || + tuo.isTriolet() || + tuo.hasHairPin() != null + } + + val noteBlockH = maxNoteLines * noteLineH + val nbStanzasToShow = if (maxSylsSize >= 2) 1 else if (nbStanza >= 2) 2 else 1 + val lyricBlockH = maxLyricLines * lyricLineH * nbStanzasToShow + val rowH = (if (rowHasMarkers) markerHeight else 0f) + noteBlockH + lyricBlockH + 10f * nbStanzasToShow + 8f // esp entre parole et note suivante + + // Vérification de la place sur la page + if (y - rowH < marginY) { + cs.close() + cs = newPage() + y = pageHeight - marginY/* - headerH*/ + } + + // Point de départ des notes (en tenant compte des marqueurs) + val notesStartY = if (rowHasMarkers) y - markerHeight else y + + // On regarde sur toute la ligne quelles voix (0,1,2,3) ont des notes + val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex() + fun singingVoiceIndices(tuo: TimeUnitObject): List { + return tuo.noteAsMultiString().split("\n").mapIndexedNotNull { li, line -> + val clean = line.trim() + // On considère que ça "chante" si ce n'est pas vide, + // pas un simple tiret de prolongation, et que ça contient une note (do, re, mi...) + val sings = clean.isNotBlank() + && !clean.all { it == '-' || it.isWhitespace() } + && clean != "—" && clean != "–" + && pitchPattern.containsMatchIn(clean) + if (sings) li else null + } + } + + + val spacings = MutableList(4) { 0 } + + rowTuos.forEach { tuo -> + val singing = singingVoiceIndices(tuo) + val rawSyls = tuo.getSingleSyllable(stanza) + + // 1. Analyse du Soprano (syl0) + val rawSyl0 = rawSyls.getOrNull(0) ?: "" + val cleanSyl0 = rawSyl0.replace(REGEX_CLEAN, "").trim() + val syl0HasRealText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) && + !cleanSyl0.all { it == '―' || it == '-' || it == '—' } + val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + + // NOUVEAU : Est-ce qu'une autre ligne (syl1, syl2...) contient un préfixe "1." ? + val uneAutreVoixPrendLeSoprano = (1..4).any { vIdx -> + val s = rawSyls.getOrNull(vIdx) ?: "" + s.contains("1.") && REGEX_HAS_LYRIC.containsMatchIn(s.replace(REGEX_CLEAN, "")) + } + + // La condition finale pour savoir si on considère qu'il y a du texte au niveau "Soprano" + val aDuTexteAuNiveauSoprano = !syl0EstVraimentVide || uneAutreVoixPrendLeSoprano + + + (1..4).forEach { vIdx -> + val rawSyl = rawSyls.getOrNull(vIdx) ?: "" + + // --- NOUVEAU FILTRE : ON IGNORE LES TIRETS MÊME AVEC PRÉFIXE --- + // val cleanContent = rawSyl.replace(REGEX_CLEAN, "").trim() + val cleanContent = cleanSyllable(rawSyl) + val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanContent) && + !cleanContent.all { it == '―' || it == '-' || it == '—' } + + if (rawSyl.isNotBlank() && hasActualText) { + val prefixInts = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + + if (prefixInts.isNotEmpty()) { + val hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4)) + val hasDuoSA = prefixInts.size == 2 && prefixInts.containsAll(listOf(1, 2)) + val hasDuoAT = prefixInts.size == 2 && prefixInts.containsAll(listOf(2, 3)) + val hasDuoTB = prefixInts.size == 2 && prefixInts.containsAll(listOf(3, 4)) + val isUnisson = prefixInts.containsAll(listOf(1, 2, 3, 4)) + + // Logique simplifiée pour le calcul des spacings (Duo consécutif) + if (prefixInts.contains(2) && prefixInts.contains(3)) { + // C'est un duo Alto-Ténor -> On ouvre l'espace APRES le Ténor + spacings[2] = 1 + spacings[1] = 0 // On force l'Alto à 0 pour éviter les doublons + } + if (isUnisson) { + // Unisson : Rien + } + else if (hasTrioATB) { + spacings[3] = 1 + } + else if (hasDuoSA) { + spacings[1] = 1 + } + /*else if (hasDuoAT) { + spacings[1] = 0 + spacings[2] = 1 + }*/ + else if (hasDuoTB) { + if (syl0HasRealText) { spacings[1] = 1 } + spacings[3] = 1 + } + else { + // Cas Standard (Individuel) + prefixInts.forEach { voiceNum -> + when (voiceNum) { + 1 -> if (syl0HasRealText) spacings[0] = 1 + 2 -> if (!prefixInts.contains(1)) spacings[1] = 1 + 3 -> if (!prefixInts.contains(2)) spacings[2] = 1 + 4 -> if (!prefixInts.contains(3)) spacings[3] = 1 + } + } + } + } + } + } + } + + val customSpacings = spacings.toList() + + + /*if (rowIdx == 7 || (rowIdx == 8)) { + println("╠══════════════════════════════════════════════════════════════╣") + println("║ customSpacings calculés : $customSpacings ") + println("╚══════════════════════════════════════════════════════════════╝") + + rowTuos.forEachIndexed { colIdx, tuo -> + val rawSyls = tuo.getSingleSyllable(stanza) + val singing = singingVoiceIndices(tuo) + + println("\n➔ COLONNE [$colIdx] | Notes actives: $singing") + + rawSyls.forEachIndexed { lineIdx, raw -> + if (raw.isNotBlank()) { + val clean = cleanSyllable(raw) + val prefixes = REGEX_PREFIX_DIGITS.findAll(raw).map { it.groupValues[1].toInt() }.toList() + val hasText = REGEX_HAS_LYRIC.containsMatchIn(clean) && !clean.all { it == '―' || it == '-' } + + print(" [Syl $lineIdx] Brute: \"$raw\"") + if (lineIdx > 0) { + print(" | Clean: \"$clean\" | Prefixes: $prefixes") + + if (prefixes.isEmpty()) { + print(" -> ⚠️ IGNORÉ (Pas de préfixe)") + } else if (!hasText) { + print(" -> ⚠️ IGNORÉ (Que des tirets/vides)") + } else { + // Simulation du placement + val targets = prefixes.map { it - 1 } + val validTargets = targets.filter { customSpacings.getOrElse(it) { 0 } == 1 } + if (validTargets.isEmpty()) { + print(" -> ❌ REJETÉ (Aucun espace ouvert dans customSpacings pour $targets)") + } else { + print(" -> ✅ PLACÉ dans slots: $validTargets") + } + } + } else { + print(" -> (Soprano Standard)") + } + println() + } + } + } + println("\n" + "═".repeat(64)) + }*/ + + // println("\n[FIN SCAN ROW $rowIdx]") + // println(" → Voix uniques détectées (ordre d'apparition) : $originalList") + // println(" → Séquence conservée : $voicesToSpace") + // println(" → CustomSpacings final pour ce Row : $customSpacings") + // println("[DEBUG] Original: $originalList | Sequence: $voicesToSpace | Result: $customSpacings") + + + // ---- 1. Marqueurs (si présents) ---- + if (rowHasMarkers) { + rowTuos.forEachIndexed { colIdx, tuo -> + val x = marginX + colIdx * colWidth + drawMarkers(cs, tuo, x, colWidth, y, markerBoldFont, markerItalicFont, markerEmmetFont, noteFontSize, emmentalerTTF) + + // ➕ AJOUT ICI : hairpins (même boucle, mêmes coords !) + drawHairPinForTUO( + cs = cs, + tuo = tuo, + gridColumnCount = colCount, // tu as déjà colCount + colX = x, + colWidth = colWidth, + markerY = y, // ton y actuel + noteLineH = noteLineH + ) + } + } + + // val customSpacings = listOf(1, 1, 1, 0) // 1 ligne vide après chaque voix + + // ---- 2. Accolade et étiquettes SATB ---- + val braceTop = notesStartY + noteLineH * 0.85f + // val braceBottom = notesStartY - (maxNoteLines - 1) * noteLineH + + val lastVoiceIdx = (maxNoteLines - 1).coerceIn(0, 3) + val totalVirtualSlots = getVirtualLineIndex(lastVoiceIdx, customSpacings) + 1 + val braceBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH + drawCurlyBrace(cs, marginX, braceTop, braceBottom) + + val lineBottom = braceBottom + + val voiceLabels = listOf("S", "A", "T", "B") + val labelFont = if (lyricFont is PDType0Font) lyricFont else customFont + val labelSize = noteFontSize * 0.85f + // Largeur réelle de l'accolade (même formule que dans drawCurlyBrace) + val braceW = ((braceTop - braceBottom) * 0.12f).coerceIn(8f, 22f) + val labelX = marginX - braceW - 3f + + voiceLabels.take(maxNoteLines).forEachIndexed { li, label -> + val vLi = getVirtualLineIndex(li, customSpacings) + val labelY = notesStartY - (vLi * noteLineH) + // val labelY = notesStartY - li * noteLineH + val labelColor = when (label) { + "S" -> Color(180, 0, 0) + "A" -> Color(0, 120, 0) + "T" -> Color(0, 0, 180) + "B" -> Color(0, 100, 120) + else -> Color.BLACK + + } + cs.saveGraphicsState() + cs.beginText() + cs.setFont(labelFont, labelSize) + cs.setNonStrokingColor(labelColor) + cs.newLineAtOffset(labelX - textWidth(labelFont, labelSize, label), labelY) + cs.showText(label) + cs.endText() + cs.restoreGraphicsState() + } + + // ---- 3. Notes ---- + // Calcul des positions des barres verticales (communes à toute la rangée) + val lineTop = notesStartY + noteLineH * 0.85f + // val lineBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH + + rowTuos.forEachIndexed { colIdx, tuo -> + val x = marginX + colIdx * colWidth + + val sepW = textWidth(customFont, noteFontSize, "|") + val noteX = when (tuo.sep0) { + "/", "|" -> { + cs.setStrokingColor(0f, 0f, 0f); cs.setLineWidth(0.8f) + cs.moveTo(x + sepW * 0.2f, lineTop) + cs.lineTo(x + sepW * 0.2f, lineBottom); cs.stroke() + if (tuo.sep0 == "/") { + cs.moveTo(x + sepW * 0.7f, lineTop) + cs.lineTo(x + sepW * 0.7f, lineBottom); cs.stroke() + } + x + sepW + 2f + } + else -> x + } + + + val noteLines = tuo.noteAsMultiString().split("\n") + + // println("\n=== ROW0 col[$colIdx] sep0='${tuo.sep0}' noteX=$noteX ===") + // println(" noteAsMultiString raw = ${tuo.noteAsMultiString().replace("\n", "\\n")}") + // println(" noteLines.size = ${noteLines.size}") + noteLines.forEachIndexed { li, line -> + val isSilence = line.isBlank() || + !pitchPattern.containsMatchIn(line) + // println(" line[$li] = '$line' isSilence=$isSilence y=${notesStartY - li * noteLineH}") + } + val syls = tuo.getSingleSyllable(stanza) + // println(" getSingleSyllable($stanza) = $syls") + // println(" notesStartY=$notesStartY noteLineH=$noteLineH") + + val nonSilencePositions = mutableListOf() + noteLines.forEachIndexed { li, line -> + val vLi = getVirtualLineIndex(li, customSpacings) + val currentY = notesStartY - (vLi * noteLineH) + + if (line.isNotBlank() && pitchPattern.containsMatchIn(line)) { + nonSilencePositions.add(li) + } + if (line.isNotBlank()) { + // ICI : Utilisation de la nouvelle fonction pour gérer les ">" + if (line.contains(">")) { + drawNoteWithModulation(cs, customFont, noteFontSize, line, noteX, currentY) + } else { + drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, currentY) + } + } + // Pour écrire dans l'espace vide JUSTE en dessous de cette voix : + val spaceCount = customSpacings.getOrElse(li) { 0 } + for (s in 1..spaceCount) { + val emptySlotY = notesStartY - ((vLi + s) * noteLineH) + // C'est ici que vous pourrez insérer vos textes additionnels plus tard + } + + // if (line.isBlank()) return@forEachIndexed + // drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, notesStartY - li * noteLineH) + } + // println(" nonSilencePositions = $nonSilencePositions") + + drawTUOUnderlines( + cs = cs, + tuo = tuo, + font = customFont, + fontSize = noteFontSize, + colX = x, + y = notesStartY, + noteLineH = noteLineH, + colWidth = colWidth, + customSpacing = customSpacings + ) + } + + // ---- 4. Paroles ---- + // Calcul du vLi de la toute dernière ligne de basse + son espace vide associé + val lastVoiceIndex = (maxNoteLines - 1).coerceIn(0, 3) + val lastVirtualIndex = getVirtualLineIndex(lastVoiceIndex, customSpacings) + // Si vous voulez que les paroles soient SOUS l'espace vide de la basse : + val totalAreaSlots = lastVirtualIndex + customSpacings[lastVoiceIndex] + // La position Y réelle du bas de l'accolade + val finalBraceBottom = notesStartY - (totalAreaSlots * noteLineH) + + val stanzasToShow = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1) + + val verticalMargin = 25f // Espace de sécurité entre l'accolade et les paroles + // val finalBraceBottom = braceBottom // On l'a calculé plus haut + // On calcule le point le plus bas pour la prochaine ligne + var lowestYOfThisRow = finalBraceBottom + + stanzasToShow.forEachIndexed { stanzaIdx, currentStanza -> + + // val yLyric = finalBraceBottom - verticalMargin - stanzaIdx * (lyricLineH * (maxLyricLines + 0.3f))//+espace + val nbOverrideLines = customSpacings.sum() + //val adjustedMargin = 13.5f // On réduit la marge de base (25f) pour rapprocher syl0 du bloc de notes + val yLyric = finalBraceBottom - adjustedMargin - (nbOverrideLines * noteLineH * 0.2f) // espace entre syl0 + + // Décalage vertical pour le 2ème couplet s'il existe + val yLyricOffset = stanzaIdx * (lyricLineH * 1.1f) + val currentYLyric = yLyric - yLyricOffset + + val allTemps: List> = rowTuos.map { it.getSingleSyllable(currentStanza) } + val maxLinesThisStanza = allTemps.maxOfOrNull { it.size } ?: 1 + + // ── DEBUG ───────────────────────────────────────────────────── +// println("\n╔══ ROW[$rowIdx] STANZA[$currentStanza] ══════════════════════════") +// println("║ currentYLyric = $currentYLyric") +// println("║ maxLines = $maxLinesThisStanza") +// println("║ lyricLineH = $lyricLineH") + + // Numéro de stanza + val stanzaNumTxt = "$currentStanza." + val stanzaNumSize = lyricFontSize * 0.85f + val stanzaNumFont = if (lyricFont is PDType0Font) lyricFont else customFont + val stanzaNumW = try { + stanzaNumFont.getStringWidth(stanzaNumTxt) / 1000f * stanzaNumSize + } catch (e: Exception) { 0f } + + cs.beginText() + cs.setFont(stanzaNumFont, stanzaNumSize) + cs.setNonStrokingColor(Color.BLACK) + cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric) + cs.showText(stanzaNumTxt) + cs.endText() + + // Syllabes + /*allTemps.forEachIndexed { sIdx, syllables -> + val x = marginX + sIdx * colWidth + val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x + + syllables.forEachIndexed { lineIdx, syl -> + val (spacedSyl, alignLeft) = spacedSyllable( + syl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize + ) + val lyricX = if (alignLeft) noteX + else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl) + + cs.beginText() + cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize) + cs.newLineAtOffset(lyricX, yLyric - lineIdx * lyricLineH) + cs.showText(spacedSyl.sanitize()) + cs.endText() + } + }*/ + fun drawInOverrideSlot( + cs: PDPageContentStream, + targetIdx: Int, // 0=S, 1=A, 2=T, 3=B, ou -1 pour le remplacement direct de syl0 + cleanSyl: String, + sIdx: Int, + lineIdx: Int, + allTemps: List>, + noteX: Float, + colWidth: Float, + customFont: PDType0Font, + lyricFontSize: Float, + notesStartY: Float, // Le point de référence (soit notesStartY pour SATB, soit yLyric pour le S) + noteLineH: Float, + customSpacings: List, + isDirectY: Boolean = false // Si true, on ignore le calcul de vLi et on utilise notesStartY direct + ) { + val lyricColor = when (targetIdx) { + -1, 0 -> Color(180, 0, 0) // Soprano (Remplacement ou standard) + 1 -> Color(0, 120, 0) // Alto + 2 -> Color(0, 0, 180) // Ténor + 3 -> Color(0, 100, 120) // Basse + else -> Color.BLACK // Par défaut (sécurité) + } + // 1. Calcul du Y final + val finalY = if (isDirectY) { + notesStartY // On se pose directement sur yLyric + } else { + // Calcul standard pour les espaces vides entre les notes + val vLi = getVirtualLineIndex(targetIdx, customSpacings) + notesStartY - ((vLi + 1) * noteLineH) + 2f + } + + // 2. Calcul de l'alignement horizontal + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX /*if (alignLeft) { + noteX // Alignement gauche sur la note + } else { + // CENTRAGE : On calcule le milieu de la colonne et on retire la moitié de la largeur du texte + val txtW = textWidth(customFont, lyricFontSize, spacedSyl) + noteX + (colWidth / 2f) - (txtW / 2f) + }*/ + // val lyricX = if (alignLeft) noteX else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl) + + // 3. Dessin + cs.saveGraphicsState() + cs.beginText() + cs.setFont(customFont, lyricFontSize * 0.9f) + cs.setNonStrokingColor(lyricColor) // Définit la couleur de remplissage du texte + cs.newLineAtOffset(lyricX, finalY) + cs.showText(spacedSyl.sanitize()) + cs.endText() + cs.restoreGraphicsState() + } + + + allTemps.forEachIndexed { sIdx, syllables -> + val x = marginX + sIdx * colWidth + val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x + + // On suit quels slots (0,1,2,3) ont déjà reçu du texte pour CETTE colonne + val filledSlots = mutableSetOf() + + val isStandardLayout = customSpacings.all { it == 0 } + + syllables.forEachIndexed { lineIdx, rawSyl -> + val expectedY = currentYLyric - lineIdx * lyricLineH +// println("║ col[$sIdx] syl[$lineIdx] '$rawSyl' → Y=$expectedY") + + if (lineIdx == 0) { + // SYL0 : On dessine normalement en bas (comportement standard) + val (spacedSyl, offsetX) = spacedSyllable(rawSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + // val lyricX = if (alignLeft) noteX else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl) + val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/ + + cs.beginText() + cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize) + cs.newLineAtOffset(lyricX, currentYLyric) + cs.showText(spacedSyl.sanitize()) + cs.endText() + } else { + // 1. D'abord, on vérifie si c'est un Solo Soprano "1." même si spacing est à 0 + val prefixes = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + val rawSyl0 = syllables.getOrNull(0) ?: "" + val cleanSyl0 = cleanSyllable(rawSyl0) + val cleanSyl = cleanSyllable(rawSyl) + + val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + + val isAllZeroSpacing = customSpacings.all { it == 0 } + + if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) { + // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0) + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/ + + cs.beginText() + cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize) + cs.newLineAtOffset(lyricX, currentYLyric) + cs.showText(spacedSyl.sanitize()) + cs.endText() + + } else if (isAllZeroSpacing) { + // ── CAS SIMPLE : pas d'espace entre notes → syl1+ descend sous syl0 ── + val cleanSyl = cleanSyllable(rawSyl) + if (cleanSyl.isNotBlank()) { + val thisLineY = currentYLyric - lineIdx * extraSylLineH + + val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize) + val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/ + + cs.beginText() + cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize) + cs.setNonStrokingColor(Color.BLACK) + cs.newLineAtOffset(lyricX, thisLineY) // thisLineY = currentYLyric - lineIdx * lyricLineH + cs.showText(spacedSyl.sanitize()) + cs.endText() + } + } else { // --- OVERRIDES (SYL 1, 2, 3...) --- + val prefixes = + REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList() + val cleanSyl = cleanSyllable(rawSyl) + + val rawSyl0 = syllables.getOrNull(0) ?: "" + val cleanSyl0 = cleanSyllable(rawSyl0) + val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + + + val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) && + !cleanSyl.all { it == '―' || it == '-' } + val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) || + cleanSyl.all { it == '―' || it == '-' || it == '—' } + + if (prefixes.isNotEmpty() && hasActualText) { + prefixes.forEach { voiceNum -> + val targetIdx = voiceNum - 1 // 0=S, 1=A, 2=T, 3=B + + // --- CAS SPÉCIAL : LE PRÉFIXE "1." (SOPRANO) --- + if (voiceNum == 1) { + // A. PRIORITÉ : Si la ligne principale est libre, on s'installe dessus + if (lignePrincipaleEstLibre && !filledSlots.contains(-1)) { + drawInOverrideSlot( + cs, -1, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth, + customFont, lyricFontSize, currentYLyric, noteLineH, customSpacings, + isDirectY = true // Pose sur yLyric + ) + filledSlots.add(-1) + } + // B. REPLI : Si la ligne 0 est occupée, on regarde si un espace (1) est ouvert dans cS + else if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) { + drawInOverrideSlot( + cs, + 0, + cleanSyl, + sIdx, + lineIdx, + allTemps, + noteX, + colWidth, + customFont, + lyricFontSize, + notesStartY - yLyricOffset, + noteLineH, + customSpacings + ) + filledSlots.add(0) + } + } else { + // --- CAS GÉNÉRAL (Alto 2, Ténor 3, Basse 4) --- + if (customSpacings.getOrElse(targetIdx) { 0 } == 1 && !filledSlots.contains( + targetIdx + )) { + drawInOverrideSlot( + cs, targetIdx, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth, + customFont, lyricFontSize, notesStartY, noteLineH, customSpacings + ) + filledSlots.add(targetIdx) + } + } + } + } + } + } + } + } +// println("╚══════════════════════════════════════════════════════════") + + val isAllZeroSpacing = customSpacings.all { it == 0 } + val maxExtraLines = if (isAllZeroSpacing) { + allTemps.maxOfOrNull { sylList -> + sylList.count { rawSyl -> + val clean = cleanSyllable(rawSyl) + REGEX_HAS_LYRIC.containsMatchIn(clean) && + !clean.all { it == '―' || it == '-' || it == '—' } + }.coerceAtLeast(1) + } ?: 1 + } else { + 1 + } + // Le vrai bas = currentYLyric - (toutes les lignes sauf syl0) * lyricLineH + val bottomOfCurrentStanza = currentYLyric - ((maxExtraLines - 1) * extraSylLineH) - 4f + + + // MAJ du point le plus bas : on retire la hauteur de la ligne de texte +// val bottomOfCurrentStanza = currentYLyric - (lyricLineH * 0.5f) + if (bottomOfCurrentStanza < lowestYOfThisRow) { + lowestYOfThisRow = bottomOfCurrentStanza + } + // val bottomOfStanza = yLyric - (maxLyricLines * lyricLineH) + // if (bottomOfStanza < lowestYOfThisRow) lowestYOfThisRow = bottomOfStanza + } + y = lowestYOfThisRow - interRowGap + } + + // ── Stanzas restantes en texte __ 1.et2. seulement + val stanzasDisplayed = if (maxSylsSize >= 2) { + listOf(1) + } else { + if (nbStanza >= 2) listOf(1, 2) else listOf(1) + } + val stanzasRemaining = (1..nbStanza).filter { it !in stanzasDisplayed } + + if (stanzasRemaining.isNotEmpty()) { + val textFont = if (lyricFont is PDType0Font) lyricFont else customFont + val textSize = lyricFontSize + val lineH = lyricLineH + val stanzaGapY = 12f + val numColsMax = 3 // max colonnes + + // Ligne séparatrice + y -= 7f + if (y < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY/* - headerH*/ } + cs.setStrokingColor(0.5f, 0.5f, 0.5f); cs.setLineWidth(0.3f) + cs.moveTo(marginX, y + 5f); cs.lineTo(marginX + usableWidth, y + 5f); cs.stroke() + y -= 6f + // y -= 14f + + // ── 1. Reconstruit lignes de texte d'une stanza ─────────────────── + fun buildStanzaText(sz: Int): List { + // println("\n══ buildStanzaText(stanza=$sz) ══") + + val normalLyrics = mutableListOf>() + val allDcLyrics = mutableListOf>() + + // 1. Collecte TOUS + tuoList.forEachIndexed { tuoIdx, tuo -> + val syls = tuo.getSingleSyllable(sz) + val normal = syls.getOrNull(0)/*?.trim()*/ ?: "" + val dcPart = syls.drop(1).joinToString(" ") { it.trim() } + + if (normal.isNotEmpty()) { + normalLyrics.add(tuoIdx to normal) + // println(" NORMAL[$tuoIdx]: '$normal'") + } + if (dcPart.isNotEmpty()) { + allDcLyrics.add(tuoIdx to dcPart) + // println(" DC[$tuoIdx]: '$dcPart'") + } + if (tuo.pTemplate.markerToString().contains("DC")) { + // println(" 🎯 DC MARQUEUR[$tuoIdx]") + } + } + + val result = StringBuilder() + var dcIndex = 0 + var inDcBlock = false + + // 2. PARCOURS TOUS les TUO (pas seulement normalLyrics) + tuoList.forEachIndexed { tuoIdx, tuo -> + val syls = tuo.getSingleSyllable(sz) + val normal = syls.getOrNull(0)?.trim() ?: "" + val dcPart = syls.drop(1).joinToString(" ") { it.trim() } + + + // ✅ 2e : NORMAL (si existe) + if (normal.isNotEmpty()) { + result.append(normal).append(" ") + // println(" → AJOUT NORMAL[$tuoIdx]: '$normal'") + } + // ✅ 1er : CROCHET DC (TOUJOURS, même si normal vide) + if (dcPart.isNotEmpty()) { + result.append("[$dcPart] ") + // println(" 🟦 CROCHET DC[$tuoIdx]: '[$dcPart]'") + } + + // 3. DC MARQUEUR (parenthèses) + val marker = tuo.pTemplate.markerToString() + if (marker.contains("DC") && !inDcBlock && allDcLyrics.isNotEmpty()) { + result.append("(") + // println(" 🎯 OUVERTURE ( à [$tuoIdx]") + + while (dcIndex < allDcLyrics.size) { + val (dcIdx, dcText) = allDcLyrics[dcIndex] + if (dcIdx > tuoIdx) break + result.append(dcText).append(" ") + // println(" → AJOUT DC[$dcIdx] DANS (") + dcIndex++ + } + + result.append(")") + // println(" 🎯 FERMETURE )") + inDcBlock = false + } + } + + val fullText = result.toString().trim() + // println(" → FINAL: '$fullText'") + return listOf(fullText) + } + + // ── 2. Wrap une ligne selon largeur max ─────────────────────────── + fun wrapLine(line: String, maxW: Float): List { + if (line.isBlank()) return listOf(line) + + val totalW = try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } + catch (e: Exception) { 0f } + + if (totalW <= maxW) return listOf(line) + + // On coupe par bloc de mot + ses espaces suivants + // La regex (\s+) capture les espaces pour les garder dans le résultat du split + val parts = mutableListOf() + val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line) + while (matcher.find()) { + parts.add(matcher.group()) + } + + val result = mutableListOf() + var current = "" + + parts.forEach { part -> + val candidate = current + part + val w = try { textFont.getStringWidth(candidate.sanitize()) / 1000f * textSize } + catch (e: Exception) { 0f } + + if (w <= maxW || current.isEmpty()) { + current = candidate + } else { + result.add(current.replace(Regex("\\s+$"), "")) // On retire l'espace de fin de ligne + current = part + } + } + if (current.isNotEmpty()) result.add(current) + return result + } + + // ── 3. Calcule la largeur naturelle max d'une stanza ───────────── + fun naturalWidth(lines: List): Float = + lines.maxOfOrNull { line -> + try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } + catch (e: Exception) { 0f } + } ?: 0f + + // ── 4. Calcule la hauteur d'une stanza (avec header "N.") ──────── + fun stanzaHeight(wrappedLines: List): Float = + lineH + wrappedLines.size * lineH + stanzaGapY + + // ── 5. Détermine le nombre optimal de colonnes ─────────────────── + // Logique : trouve le min de colonnes pour tenir sur 1 page + val availH = y - marginY + + val allStanzaRaw = stanzasRemaining.map { sz -> sz to buildStanzaText(sz) } + + // Largeur naturelle max → détermine colWidth candidat + val maxNatW = allStanzaRaw.maxOfOrNull { (_, lines) -> naturalWidth(lines) } ?: (usableWidth / 2) + + // Essaie ncols de 1 à numColsMax, prend le plus grand qui tient + fun fitsInOnePage(nCols: Int): Boolean { + val cW = usableWidth / nCols + val innerW = cW - 10f + // Wrap toutes les stanzas + val wrapped = allStanzaRaw.map { (sz, lines) -> + sz to lines.flatMap { wrapLine(it, innerW) } + } + // Répartit équitablement : remplissage colonne par colonne + // puis vérifie que chaque colonne tient dans availH + val colHeights = Array(nCols) { 0f } + wrapped.forEachIndexed { idx, (_, wLines) -> + val col = idx % nCols + colHeights[col] += stanzaHeight(wLines) + } + return colHeights.all { it <= availH } + } + + // ── 5. Colonnes selon nb de stanzas restantes ──────────────────────── + val optimalCols: Int + val colW: Float + val innerW: Float + + when (stanzasRemaining.size) { + 1 -> { + // Pleine largeur, pas d'optimisation + optimalCols = 1 + colW = usableWidth + innerW = colW - 10f + } + 2 -> { + // Moitié chacune, pas d'optimisation + optimalCols = 2 + colW = usableWidth / 2f + innerW = colW - 10f + } + else -> { + // 3+ : cherche le meilleur nb de colonnes (max 3) pour tenir sur 1 page + val best = (1..3).lastOrNull { nCols -> + val cWTest = usableWidth / nCols + val innerWTest = cWTest - 10f + val wrapped = allStanzaRaw.map { (sz, lines) -> + sz to lines.flatMap { wrapLine(it, innerWTest) } + } + val colHeights = Array(nCols) { 0f } + wrapped.forEachIndexed { idx, (_, wLines) -> + colHeights[idx % nCols] += stanzaHeight(wLines) + } + colHeights.all { it <= availH } + } ?: 3 + + optimalCols = best + colW = usableWidth / optimalCols + innerW = colW - 10f + } + } + + // ── 6. Wrap final avec colW optimal ────────────────────────────── + val stanzaWrapped = allStanzaRaw.map { (sz, lines) -> + sz to lines.flatMap { wrapLine(it, innerW) } + } + + // ── 7. Répartition équilibrée dans les colonnes ────────────────── + // Algorithme : remplit colonne par colonne, change de col quand débordement + // ── 7. Répartition ROW par ROW (gauche→droite, ligne par ligne) ── + data class ColItem(val sz: Int, val lines: List) + + // Découpe en rangées de optimalCols stanzas + val rows2 = stanzaWrapped.chunked(optimalCols) + + // ── 8. Dessin row par row ───────────────────────────────────────── + var rowStartY = y + + rows2.forEach { rowItems -> + // Hauteur max de cette rangée + val rowH2 = rowItems.maxOfOrNull { (_, wLines) -> stanzaHeight(wLines) } ?: 0f + + // Nouvelle page si la rangée entière ne rentre pas + if (rowStartY - rowH2 < marginY) { + cs.close(); cs = newPage() + rowStartY = pageHeight - marginY/* - headerH*/ + } + + // Dessine chaque stanza de la rangée dans sa colonne + rowItems.forEachIndexed { colIdx, (sz, wrappedLines) -> + val colX = marginX + colIdx * colW + var colY = rowStartY + + // Numéro de stanza + cs.beginText() + cs.setFont(textFont, textSize * 0.9f) + cs.newLineAtOffset(colX, colY) + cs.showText("$sz.") + cs.endText() + colY -= lineH + + // Lignes + wrappedLines.forEach { line -> + cs.beginText() + cs.setFont(textFont, textSize) + cs.newLineAtOffset(colX + 10f, colY) + cs.showText(line.sanitize()) + cs.endText() + colY -= lineH + } + } + + // Avance y après la rangée la plus haute + rowStartY -= (rowH2 + stanzaGapY) + } + y = rowStartY + } + cs.close() + + val outputDir = System.getProperty("user.home") + "/Documents" + JFile(outputDir).mkdirs() + + val totalUsableHeight = pageHeight - (2 * marginY) + val remainingHeight = (y - marginY).coerceAtLeast(0f) + val percentageRemaining = (remainingHeight / totalUsableHeight) * 100 + val percentageUsed = 100f - percentageRemaining + val rStr = String.format("%.2f", percentageRemaining).replace(",", ".") + val uStr = String.format("%.2f", percentageUsed).replace(",", ".") + + + val cleanTitle = songTitle.replace(" ", "_") + /*val fileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf"//$fileName + val outputPath = "$outputDir/" + + doc.save(outputPath) + doc.close() + val os = System.getProperty("os.name").lowercase() + when { + os.contains("linux") -> Runtime.getRuntime().exec(arrayOf("xdg-open", outputDir)) + os.contains("mac") -> Runtime.getRuntime().exec(arrayOf("open", outputDir)) + os.contains("win") -> Runtime.getRuntime().exec(arrayOf("explorer", outputDir)) + } + emmentalerTTF.close() + println("✅ PDF : $outputPath")*/ + val computedFileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf" + + // Sérialiser en mémoire + val outputStream = ByteArrayOutputStream() + doc.save(outputStream) + doc.close() + emmentalerTTF.close() + return Pair(outputStream.toByteArray(), computedFileName) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6cd9c2d..43013ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }