diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt index eaaf3c1..3f3469c 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -69,9 +69,9 @@ private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): private fun bestColWidthPt( tuoList: List, - font: org.apache.pdfbox.pdmodel.font.PDType0Font, + font: PDType0Font, noteFontSize: Float, - lyricFont: org.apache.pdfbox.pdmodel.font.PDType0Font, + lyricFont:PDType0Font, lyricFontSize: Float ): Float { var maxNoteW = 0f @@ -269,8 +269,12 @@ private fun drawNoteWithSubscript( // ── drawHeader ──────────────────────────────────────────────────────────────── private fun drawHeader( cs: PDPageContentStream, - noteFont: PDType0Font, lyricFont: PDType0Font?, - pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float, + noteFont:PDType0Font, + lyricFont:PDType0Font?, + bolderFont: PDType0Font?, + emmentalerfFont: PDType0Font?, + pageWidth: Float, pageHeight: Float, marginX: Float, marginY: Float, + usableWidth: Float, songTitle: String, songAuthor: String, songRhythm: String, songComposer: String, songKey: String, measure: String, stanza: Int, nbStanza: Int @@ -278,46 +282,100 @@ private fun drawHeader( val titleFont = lyricFont ?: noteFont val normalFont = lyricFont ?: noteFont val smallFont = noteFont + val emmentalerFont = emmentalerfFont ?: noteFont + val boldFont = bolderFont ?: noteFont fun tw(f: PDType0Font, sz: Float, t: String) = try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f } + // ── Y calculés depuis marginY au lieu de valeurs fixes ──────────────────── + val titleY = pageHeight - marginY - 5f + val line2Y = pageHeight - marginY - 21f + val line3Y = pageHeight - marginY - 33f + val sepY = line3Y - 5f + // Ligne 1 — Titre centré val titleSize = 15f - val titleW = tw(titleFont, titleSize, songTitle) + 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() + cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, titleY) + cs.showText(songTitle.sanitize()) + cs.endText() - // Ligne 2 — Auteur | Compositeur - val line2Y = pageHeight - 64f; val sz2 = 9f + // Ligne 2 — Author | Composer (droite) + 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.beginText(); + cs.setFont(normalFont, sz2) cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y) - cs.showText(songComposer.sanitize()); cs.endText() + 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() + // Ligne 3 — Key | Measure | Rhythm + val sz3 = 10f + val keyTxt = "Dô dia " + 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 keyW_2 = tw(smallFont, sz3, keyTxt) + val keyTxt2 = "${songKey.sanitize()}" + cs.beginText(); cs.setFont(boldFont, sz3+2f) + cs.newLineAtOffset(marginX + keyW_2 + 3f, line3Y); + cs.showText(keyTxt2); + cs.endText() + + val keyW = tw(boldFont, sz3, keyTxt+keyTxt2) + 10f + val sz32 = 14f + val splitMeaRgx = """(\d+/\d+)\s+(.*)""".toRegex() + val matchRes = splitMeaRgx.matchEntire(measure) + var timeSignature = "" + var titleSignature = "" + if(matchRes != null) { + timeSignature = matchRes.groupValues[1] + titleSignature = matchRes.groupValues[2] + } + val timesig = timeSignature.toCharArray() + + var ksW = keyW + timesig.forEach { c -> + cs.beginText(); + if(c == '/') { + cs.setFont(normalFont, sz32) + cs.newLineAtOffset(marginX + ksW + 10f, line3Y) + ksW += tw(normalFont, sz32, c.toString()) + } else { + cs.setFont(emmentalerFont, sz32) + cs.newLineAtOffset(marginX + ksW + 10f, line3Y) + ksW += tw(emmentalerFont, sz32, c.toString()) + } + cs.showText(c.toString()); + cs.endText() + } + cs.beginText(); + cs.setFont(smallFont, sz3) + cs.newLineAtOffset(marginX + ksW + 15f, line3Y) + cs.showText("${titleSignature}"); + cs.endText() val rhythmW = tw(normalFont, sz3, songRhythm.sanitize()) - cs.beginText(); cs.setFont(normalFont, sz2) + cs.beginText(); + cs.setFont(normalFont, sz2) cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y) - cs.showText(songRhythm.sanitize()); cs.endText() + 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() + cs.setStrokingColor(0.6f, 0.6f, 0.6f); + cs.setLineWidth(0.4f) + cs.moveTo(marginX, sepY); + cs.lineTo(marginX + usableWidth, sepY); + cs.stroke() } // ── actual fun ──────────────────────────────────────────────────────────────── @@ -333,8 +391,8 @@ actual fun rememberPdfExportAction( songAut: String, songComp: String, songRythm: String -): () -> Unit { - return { +): (PrintSettings) -> Unit { + return { settings: PrintSettings -> scope.launch(Dispatchers.IO) { val (pdfBytes, computedFileName) = generatePdfToBytes( gridData, songTitle, measure, stanza, @@ -360,7 +418,8 @@ private suspend fun generatePdfToBytes( songKey: String, songAut: String, songComp: String, - songRythm: String + songRythm: String, + settings: PrintSettings = defaultPrintSettings() ): Pair { val tuoList = gridData.tuoList.drop(1) @@ -370,20 +429,21 @@ private suspend fun generatePdfToBytes( 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 markerHeight = settings.markerHeight + val noteFontSize = settings.noteFontSize + val lyricFontSize = settings.lyricFontSize + val noteLineH = settings.noteLineH + val lyricLineH = settings.lyricLineH + val extraSylLineH = settings.extraSylLineH + val adjustedMargin = settings.adjustedMargin + val interRowGap = settings.interRowGap + val marginX = settings.marginX + val marginY = settings.marginY + val headerH = settings.headerH + val pageWidth = settings.pageWidth + val pageHeight = settings.pageHeight val usableWidth = pageWidth - 2 * marginX + val customNbGrid = settings.nbGridOnMeasure // ── Polices ──────────────────────────────────────────────────────────────── val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf") @@ -402,8 +462,12 @@ private suspend fun generatePdfToBytes( // ── Largeur optimale de colonne ──────────────────────────────────────── val bestColW = bestColWidthPt(tuoList, customFont, noteFontSize, lyricFont, lyricFontSize) - val colCount = ((usableWidth / bestColW).toInt()).coerceAtLeast(1) - val colWidth = bestColW + val colCount = if (customNbGrid == 0) { + ((usableWidth / bestColW).toInt()).coerceAtLeast(1) + } else { + customNbGrid + } + val colWidth = usableWidth / colCount val maxSylsSize = tuoList.maxOfOrNull { tuo -> (1..nbStanza).maxOfOrNull { sz -> tuo.getSingleSyllable(sz).size } ?: 1 @@ -411,7 +475,7 @@ private suspend fun generatePdfToBytes( // ── Fonctions de dessin locales ──────────────────────────────────────── fun newPage(): PDPageContentStream { - val page = PDPage(PDRectangle.A4) + val page = PDPage(settings.pdRectangle) doc.addPage(page) return PDPageContentStream(doc, page) } @@ -451,27 +515,78 @@ private suspend fun generatePdfToBytes( 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) + fun drawFermata(cs: PDPageContentStream, x: Float, y: Float, scale: Float = 1f) { + fun px(svgX: Float) = x + svgX * scale + fun py(svgY: Float) = y + svgY * scale + + fun curveTo( + cp1x: Float, cp1y: Float, + cp2x: Float, cp2y: Float, + ex: Float, ey: Float + ) = cs.curveTo(px(cp1x), py(cp1y), px(cp2x), py(cp2y), px(ex), py(ey)) + + fun drawEllipse(cx: Float, cy: Float, rx: Float, ry: Float) { + val k = 0.5523f + val left = cx - rx; val right = cx + rx + val bottom = cy - ry; val top = cy + ry + + cs.moveTo(px(right), py(cy)) + // quart haut-droit + cs.curveTo( + px(right), py(cy + ry * k), + px(cx + rx * k), py(top), + px(cx), py(top) + ) + // quart haut-gauche + cs.curveTo( + px(cx - rx * k), py(top), + px(left), py(cy + ry * k), + px(left), py(cy) + ) + // quart bas-gauche + cs.curveTo( + px(left), py(cy - ry * k), + px(cx - rx * k), py(bottom), + px(cx), py(bottom) + ) + // quart bas-droit + cs.curveTo( + px(cx + rx * k), py(bottom), + px(right), py(cy - ry * k), + px(right), py(cy) + ) + cs.closePath() + } + + cs.setNonStrokingColor(0f, 0f, 0f) // RGB noir + cs.moveTo(px(0.3034f), py(0.6922f)) + + // Dôme extérieur — côté gauche + curveTo(2.6605f, 16.6051f, 13.8570f, 30.7498f, 28.0000f, 30.7498f) + // Dôme extérieur — côté droit + curveTo(42.1430f, 30.7498f, 53.3395f, 16.6051f, 55.6966f, 0.6922f) + + // Épaisseur bord droit + curveTo(55.8145f, 0.1028f, 52.8680f, 0.1028f, 52.7502f, 0.6922f) + + // Dôme intérieur — côté droit + curveTo(49.8037f, 11.8901f, 42.1430f, 22.4987f, 28.0000f, 22.4987f) + // Dôme intérieur — côté gauche + curveTo(13.8570f, 22.4987f, 6.1963f, 11.8901f, 3.2498f, 0.6922f) + + // Épaisseur bord gauche + curveTo(3.1320f, 0.1028f, 0.1855f, 0.1028f, 0.3034f, 0.6922f) + + cs.closePath() + cs.fill() + + drawEllipse( + cx = 28.0000f, + cy = 5.8492f, + rx = 5.5983f, + ry = 5.5990f + ) 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 ──────────────────────────────────────────────────────── @@ -493,7 +608,7 @@ private suspend fun generatePdfToBytes( // 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) + drawFermata(cs, colX + colWidth / 2 - 5f, markerY - 2f, 0.3f) } // Marqueur texte @@ -540,8 +655,8 @@ private suspend fun generatePdfToBytes( var y = pageHeight - marginY - headerH drawHeader( - cs, customFont, lyricFont, - pageWidth, pageHeight, marginX, usableWidth, + cs, customFont, lyricFont, markerBoldFont, markerEmmetFont, + pageWidth, pageHeight, marginX, marginY, usableWidth, songTitle = songTitle, songAuthor = songAut, songRhythm = songRythm, @@ -812,7 +927,7 @@ private suspend fun generatePdfToBytes( val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || cleanSyl0.all { it == '―' || it == '-' || it == '—' } - val isAllZeroSpacing = customSpacings.all { it == 0 } +// val isAllZeroSpacing = customSpacings.all { it == 0 } if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) { // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0) diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt new file mode 100644 index 0000000..ada3914 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt @@ -0,0 +1,51 @@ +package mg.dot.feufaro.pdf + +import com.tom_roush.pdfbox.pdmodel.common.PDRectangle + +actual data class PrintSettings actual constructor( + actual val pageSize : PageSize, + actual val orientation : PageOrientation, + actual val markerHeight : Float, + actual val noteFontSize : Float, + actual val lyricFontSize : Float, + actual val noteLineH : Float, + actual val lyricLineH : Float, + actual val extraSylLineH : Float, + actual val adjustedMargin : Float, + actual val interRowGap : Float, + actual val marginX : Float, + actual val marginY : Float, + actual val headerH : Float, + actual val nbGridOnMeasure: Int +) { + val pdRectangle: PDRectangle + get() { + val base = when (pageSize) { + PageSize.A4 -> PDRectangle.A4 + PageSize.A3 -> PDRectangle.A3 + PageSize.LETTER -> PDRectangle.LETTER + } + return if (orientation == PageOrientation.PORTRAIT) base + else PDRectangle(base.height, base.width) + } + + actual val pageWidth : Float get() = pdRectangle.width + actual val pageHeight : Float get() = pdRectangle.height +} + +actual fun defaultPrintSettings(): PrintSettings = PrintSettings( + pageSize = PageSize.A4, + orientation = PageOrientation.PORTRAIT, + markerHeight = 16f, + noteFontSize = 12f, + lyricFontSize = 12.5f, + noteLineH = 17f, + lyricLineH = 15f, + extraSylLineH = 15f, + adjustedMargin = 14f, + interRowGap = 22f, + marginX = 42.5f, + marginY = 42.5f, + headerH = 55f, + nbGridOnMeasure = 0 +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt index 093dd36..bda0801 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -16,4 +16,4 @@ expect fun rememberPdfExportAction( songAut: String, songComp: String, songRythm: String -): () -> Unit \ No newline at end of file +): (PrintSettings) -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt new file mode 100644 index 0000000..e6d9d1e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt @@ -0,0 +1,48 @@ +package mg.dot.feufaro.pdf + +enum class PageSize(val label: String) { + A4("A4"), + A3("A3"), + LETTER("Letter") +} + +enum class PageOrientation(val label: String) { + PORTRAIT("Portrait"), + LANDSCAPE("Paysage") +} + +expect class PrintSettings( + pageSize : PageSize, + orientation : PageOrientation, + markerHeight : Float, + noteFontSize : Float, + lyricFontSize : Float, + noteLineH : Float, + lyricLineH : Float, + extraSylLineH : Float, + adjustedMargin : Float, + interRowGap : Float, + marginX : Float, + marginY : Float, + headerH : Float, + nbGridOnMeasure : Int +) { + val pageSize : PageSize + val orientation : PageOrientation + val markerHeight : Float + val noteFontSize : Float + val lyricFontSize : Float + val noteLineH : Float + val lyricLineH : Float + val extraSylLineH : Float + val adjustedMargin : Float + val interRowGap : Float + val marginX : Float + val marginY : Float + val headerH : Float + val nbGridOnMeasure : Int + val pageWidth : Float + val pageHeight : Float +} + +expect fun defaultPrintSettings(): PrintSettings \ No newline at end of file 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 2eef60a..14ac9bd 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -560,7 +560,9 @@ fun LazyVerticalGridTUO( val itemMinBaseWidth = bestTUOWidth(tuoList) val gridWidthDp = with(density) { gridWidthPx.toDp() } - val gridColumnCount: Int = remember(gridWidthDp, itemMinBaseWidth, columnGroup) { + val gridCount by sharedScreenModel.gridCount.collectAsState() + + var gridColumnCount: Int = remember(gridWidthDp, itemMinBaseWidth, columnGroup) { if (gridWidthDp == 0.dp) return@remember columnGroup var calculatedCols = (gridWidthDp / (itemMinBaseWidth + horizontalArrangementSpacing)).toInt() @@ -572,6 +574,7 @@ fun LazyVerticalGridTUO( val actualColumnCount: Int = calculatedCols.coerceAtLeast(columnGroup) actualColumnCount } + gridColumnCount += gridCount val flowRowSize: Float = 0.98f / gridColumnCount val currentStanza = viewModel.stanza 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 59c2e46..d22f025 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -18,14 +18,17 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape 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.PrintSettings +import mg.dot.feufaro.pdf.defaultPrintSettings +import mg.dot.feufaro.ui.PrintSettingsDialog import mg.dot.feufaro.pdf.rememberPdfExportAction import mg.dot.feufaro.solfa.Solfa import mg.dot.feufaro.viewmodel.SolfaScreenModel @@ -111,7 +114,10 @@ LaunchedEffect(isPlay, isPos) { ) }, content = { val scope = rememberCoroutineScope() - val pdfExportAction: () -> Unit = rememberPdfExportAction( + var showPrintSettings by remember { mutableStateOf(false) } + var pendingPrintSettings by remember { mutableStateOf(null) } + + val pdfExportAction: (PrintSettings) -> Unit = rememberPdfExportAction( scope = scope, fileRepository = solfaScreenModel.fileRepository, gridData = sharedScreenModel.currentGridData, @@ -124,6 +130,17 @@ LaunchedEffect(isPlay, isPos) { songComp = songComposer, songRythm = songRhythm ) + + if (showPrintSettings) { + PrintSettingsDialog( + initialSettings = defaultPrintSettings(), + onDismiss = { showPrintSettings = false }, + onConfirm = { settings -> + showPrintSettings = false + pdfExportAction(settings) + } + ) + } Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( modifier = Modifier.height(55.dp).windowInsetsPadding(WindowInsets.statusBars), title = { @@ -171,6 +188,42 @@ LaunchedEffect(isPlay, isPos) { modifier = Modifier.fillMaxWidth().padding(5.dp), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(7.dp) ) { + AnimatedVisibility( + visible = isExpanded and !showMidiCtrl, + enter = fadeIn() + scaleIn() + slideInVertically { it / 2 }, + exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 } + ) { + Row { + Column { + FloatingActionButton( + onClick = { + sharedScreenModel.descGridCount(1) + }, modifier = Modifier.size(30.dp).alpha(0.45f) + ) { + Icon( + Icons.Default.Remove, + contentDescription = null, + tint = Color.Blue + ) + } + } + Column { + FloatingActionButton( + onClick = { + sharedScreenModel.addGridCount(1) + }, modifier = Modifier.size(30.dp).alpha(0.45f) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = Color.Blue + ) + } + } + } + + + } AnimatedVisibility( visible = isExpanded and !showMidiCtrl, enter = fadeIn() + scaleIn() + slideInVertically { it / 2 }, @@ -198,7 +251,7 @@ LaunchedEffect(isPlay, isPos) { ) { FloatingActionButton( onClick = { - pdfExportAction() + showPrintSettings = !showPrintSettings }, modifier = Modifier.alpha(0.45f) ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PrintSettingsDialog.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PrintSettingsDialog.kt new file mode 100644 index 0000000..d873845 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PrintSettingsDialog.kt @@ -0,0 +1,653 @@ +package mg.dot.feufaro.ui + + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import mg.dot.feufaro.pdf.PageOrientation +import mg.dot.feufaro.pdf.PageSize +import mg.dot.feufaro.pdf.PrintSettings +import mg.dot.feufaro.pdf.defaultPrintSettings +import kotlin.math.roundToInt + + +@Composable +fun PrintSettingsDialog( + initialSettings: PrintSettings = defaultPrintSettings(), + onDismiss: () -> Unit, + onConfirm: (PrintSettings) -> Unit, +) { + // ── État local (copie mutable des paramètres) ───────────────────────────── + var pageSize by remember { mutableStateOf(initialSettings.pageSize) } + var orientation by remember { mutableStateOf(initialSettings.orientation) } + var noteFontSize by remember { mutableStateOf(initialSettings.noteFontSize) } + var lyricFontSize by remember { mutableStateOf(initialSettings.lyricFontSize) } + var markerHeight by remember { mutableStateOf(initialSettings.markerHeight) } + var noteLineH by remember { mutableStateOf(initialSettings.noteLineH) } + var lyricLineH by remember { mutableStateOf(initialSettings.lyricLineH) } + var extraSylLineH by remember { mutableStateOf(initialSettings.extraSylLineH) } + var adjustedMargin by remember { mutableStateOf(initialSettings.adjustedMargin) } + var interRowGap by remember { mutableStateOf(initialSettings.interRowGap) } + var marginX by remember { mutableStateOf(initialSettings.marginX) } + var marginY by remember { mutableStateOf(initialSettings.marginY) } + var headerH by remember { mutableStateOf(initialSettings.headerH) } + var nbGridOnMeasure by remember { mutableStateOf(initialSettings.nbGridOnMeasure) } + + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth(0.92f) + .fillMaxHeight(0.92f), + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp, + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.fillMaxSize()) { + + // ── Barre de titre ──────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Paramètres d'impression", + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon( + Icons.Filled.Close, + contentDescription = "Fermer", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + // ── Corps scrollable en deux colonnes ───────────────────────── + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + // ── Colonne gauche : Format papier + Preview ────────────── + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SectionTitle("Format de page") + + Row{ + Column( + modifier = Modifier.fillMaxWidth(0.7f) + ) { + LabelRow("Nombre de colonne d'une portée") + } + Column { + TextField( + value = if (nbGridOnMeasure == 0) "" else nbGridOnMeasure.toString(), + onValueChange = { newValue -> + val number = newValue.toIntOrNull()?.coerceAtMost(25) + if (number != null) { + nbGridOnMeasure = number + } else if (newValue.isEmpty()) { + nbGridOnMeasure = 0 + } + }, + modifier = Modifier.height(50.dp) + ) + } + } + + Spacer(Modifier.height(4.dp)) + + // Taille papier + LabelRow("Taille du papier") + SegmentedRow( + options = PageSize.entries.map { it.label }, + selected = pageSize.ordinal, + onSelect = { pageSize = PageSize.entries[it] } + ) + + Spacer(Modifier.height(4.dp)) + + // Orientation + LabelRow("Orientation") + SegmentedRow( + options = PageOrientation.entries.map { it.label }, + selected = orientation.ordinal, + onSelect = { orientation = PageOrientation.entries[it] } + ) + + Spacer(Modifier.height(8.dp)) + + // ── Preview miniature de la page ────────────────────── + SectionTitle("Aperçu") + val currentSettings = PrintSettings( + pageSize = pageSize, + orientation = orientation, + noteFontSize = noteFontSize, + lyricFontSize = lyricFontSize, + markerHeight = markerHeight, + noteLineH = noteLineH, + lyricLineH = lyricLineH, + extraSylLineH = extraSylLineH, + adjustedMargin = adjustedMargin, + interRowGap = interRowGap, + marginX = marginX, + marginY = marginY, + headerH = headerH, + nbGridOnMeasure = nbGridOnMeasure + ) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + PagePreview( + settings = currentSettings, + modifier = Modifier + .height(250.dp) // Hauteur fixe demandée + .padding(vertical = 8.dp) + ) + } + } + + // ── Colonne droite : Paramètres typographiques / marges ─── + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SectionTitle("Typographie") + FloatSlider( + label = "Taille fonte notes", + value = noteFontSize, + range = 8f..18f, + unit = "pt" + ) { noteFontSize = it } + + FloatSlider( + label = "Taille fonte paroles", + value = lyricFontSize, + range = 8f..18f, + unit = "pt" + ) { lyricFontSize = it } + + Spacer(Modifier.height(4.dp)) + SectionTitle("Espacements") + + FloatSlider( + label = "Hauteur zone marqueurs", + value = markerHeight, + range = 8f..32f, + unit = "mm" + ) { markerHeight = it } + + FloatSlider( + label = "Espace entre voix", + value = noteLineH, + range = 10f..34f, + unit = "mm" + ) { noteLineH = it } + + FloatSlider( + label = "Espace lignes paroles restantes", + value = lyricLineH, + range = 10f..30f, + unit = "mm" + ) { lyricLineH = it } + + FloatSlider( + label = "Espace syl0 / syl1+", + value = extraSylLineH, + range = 8f..30f, + unit = "mm" + ) { extraSylLineH = it } + + FloatSlider( + label = "Marge ajustée syl0", + value = adjustedMargin, + range = 4f..30f, + unit = "mm" + ) { adjustedMargin = it } + + FloatSlider( + label = "Écart entre portées", + value = interRowGap, + range = 10f..60f, + unit = "mm" + ) { interRowGap = it } + + Spacer(Modifier.height(4.dp)) + SectionTitle("Marges") + + FloatSlider( + label = "Marge gauche/droite", + value = marginX, + range = 15f..80f, + unit = "mm" + ) { marginX = it } + + FloatSlider( + label = "Marge haut/bas", + value = marginY, + range = 15f..80f, + unit = "mm" + ) { marginY = it } + + FloatSlider( + label = "Hauteur en-tête", + value = headerH, + range = 49f..90f, + unit = "mm" + ) { headerH = it } + } + } + + // ── Boutons bas ─────────────────────────────────────────────── + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + // Réinitialiser + TextButton(onClick = { + val def = defaultPrintSettings() + pageSize = def.pageSize + orientation = def.orientation + noteFontSize = def.noteFontSize + lyricFontSize = def.lyricFontSize + markerHeight = def.markerHeight + noteLineH = def.noteLineH + lyricLineH = def.lyricLineH + extraSylLineH = def.extraSylLineH + adjustedMargin = def.adjustedMargin + interRowGap = def.interRowGap + marginX = def.marginX + marginY = def.marginY + headerH = def.headerH + }) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + +// Spacer(Modifier.weight(1f)) + + OutlinedButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + + Button(onClick = { + onConfirm( + PrintSettings( + pageSize = pageSize, + orientation = orientation, + noteFontSize = noteFontSize, + lyricFontSize = lyricFontSize, + markerHeight = markerHeight, + noteLineH = noteLineH, + lyricLineH = lyricLineH, + extraSylLineH = extraSylLineH, + adjustedMargin = adjustedMargin, + interRowGap = interRowGap, + marginX = marginX, + marginY = marginY, + headerH = headerH, + nbGridOnMeasure = nbGridOnMeasure + ) + ) + }) { + Icon( + imageVector = Icons.Default.PictureAsPdf, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } +} + +// ── Composants internes ─────────────────────────────────────────────────────── + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + HorizontalDivider( + modifier = Modifier.padding(bottom = 4.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + ) +} + +@Composable +private fun LabelRow(text: String) { + Text( + text = text, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun SegmentedRow( + options: List, + selected: Int, + onSelect: (Int) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(8.dp) + ) + ) { + options.forEachIndexed { index, label -> + val isSelected = index == selected + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .background( + if (isSelected) MaterialTheme.colorScheme.primary + else Color.Transparent + ) + .clickable { onSelect(index) } + .padding(vertical = 8.dp) + ) { + Text( + text = label, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +/** + * Slider flottant avec label + valeur affichée. + */ +fun Float.toMm(): Float { + return this * 0.3528f +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FloatSlider( + label: String, + value: Float, + range: ClosedFloatingPointRange, + unit: String, + onValueChange: (Float) -> Unit +) { + val displayValue = if (unit == "mm") value.toMm() else value + val roundedDisplayValue = (displayValue * 2).roundToInt() / 2f + val primaryTeal = Color(0xFF009688) + val lightTeal = Color(0xFFB2DFDB) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + // 1. Label de description fixe à gauche + Text( + text = label, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // 2. Zone du Slider avec la valeur flottante au-dessus + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val widthPx = constraints.maxWidth.toFloat() + + // Calcul de la position X du texte pour suivre le thumb + // On normalise la valeur entre 0.0 et 1.0 + val fraction = (value - range.start) / (range.endInclusive - range.start) + val textOffset = (widthPx * fraction) + + // Affichage de la valeur qui "flotte" au-dessus du thumb + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) // Espace pour le texte + ) { + Text( + text = "$roundedDisplayValue $unit", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = primaryTeal, + modifier = Modifier.graphicsLayer() { + translationX = textOffset - (30.dp.toPx()) // Ajustement pour centrer le texte + } + ) + } + Slider( + value = value, + onValueChange = { onValueChange((it * 2).roundToInt() / 2f) }, + valueRange = range, + steps = (((range.endInclusive - range.start) / 0.5f).toInt() - 1).coerceAtLeast(0), + modifier = Modifier.fillMaxWidth().padding(top = 15.dp), // Décale le slider sous le texte + thumb = { + Box( + modifier = Modifier + .size(15.dp) + .background(primaryTeal, CircleShape) + ) + }, + track = { sliderState -> + SliderDefaults.Track( + sliderState = sliderState, + modifier = Modifier.height(8.dp), + colors = SliderDefaults.colors( + activeTrackColor = primaryTeal, + inactiveTrackColor = lightTeal + ), + drawStopIndicator = null + ) + } + ) + } + } +} +/** + * Mini preview de la page + */ +@Composable +private fun PagePreview( + settings: PrintSettings, + modifier: Modifier = Modifier +) { + val isLandscape = settings.orientation == PageOrientation.LANDSCAPE + val ratio = if (isLandscape) 1.414f else 0.707f + val baseScale = 200f / 595f // Échelle pour l'affichage + + Box( + modifier = modifier + .aspectRatio(ratio) + .clip(RoundedCornerShape(4.dp)) + .background(Color.White) + .border(1.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(4.dp)), + contentAlignment = Alignment.TopStart + ) { + // Conversion des unités en DP + val mx = (settings.marginX * baseScale).dp + val my = (settings.marginY * baseScale).dp + val hH = (settings.headerH * baseScale).dp + val nH = (settings.noteLineH * baseScale).dp // Espace entre 2 voix + val iG = (settings.interRowGap * baseScale).dp // Entre portées + val mH = (settings.markerHeight * baseScale).dp // Zone orange + val aM = (settings.adjustedMargin * baseScale).dp // Marge syl0 + val eS = (settings.extraSylLineH * baseScale).dp // Espace syl0/syl1 + val pR = (settings.lyricLineH * baseScale).dp // Espace syl0/syl1 + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = mx, vertical = my) + ) { + // 1. HEADER + Box( + modifier = Modifier + .fillMaxWidth() + .height(hH) + .border(0.5.dp, Color.Red.copy(alpha = 0.5f)) + .background(Color.Red.copy(alpha = 0.05f)) + ) + + Spacer(modifier = Modifier.height(iG / 2)) + + // 2. PORTÉES + val repeatedPort = if(settings.orientation == PageOrientation.LANDSCAPE) 2 else 3 + repeat(repeatedPort) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(0.5.dp, Color(0xFF2ECC71).copy(alpha = 0.4f)) // Cadre Vert + .padding(2.dp) + ) { + // Ligne des Marqueurs + Box( + modifier = Modifier + .fillMaxWidth() + .height(mH / 2) + .border(0.5.dp, Color(0xFFE67E22)) // Orange + .background(Color(0xFFE67E22).copy(alpha = 0.1f)) + ) + + // Lignes des Notes par voix + Row(verticalAlignment = Alignment.CenterVertically) { + AccoladeShape(modifier = Modifier.width(6.dp).height(nH * 2.5f)) + + Column(modifier = Modifier.weight(1f).padding(start = 2.dp)) { + repeat(4) { + Box( + modifier = Modifier + .fillMaxWidth(0.9f) + .height(nH / 3) + .border(0.3.dp, Color(0xFF00BCD4)) // Bleu Ciel + .background(Color(0xFF00BCD4).copy(alpha = 0.1f)) + ) + Spacer(modifier = Modifier.height(nH / 6)) + } + } + } + + // Paroles Syl0 et Syl1 + Spacer(modifier = Modifier.height(aM / 2)) + Column( + modifier = Modifier + .fillMaxWidth(0.85f) + .border(0.5.dp, Color(0xFF9B59B6)) // Violet + .padding(1.dp) + ) { + // Syl0 + Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(Color(0xFF9B59B6).copy(alpha = 0.3f))) + Spacer(modifier = Modifier.height(eS / 4)) + // Syl1 + Box(modifier = Modifier.fillMaxWidth(0.9f).height(2.dp).background(Color(0xFF9B59B6).copy(alpha = 0.3f))) + } + } + Spacer(modifier = Modifier.height(iG)) + } + + // 3. PAROLES RESTANTES + Column( + modifier = Modifier + .fillMaxWidth() + .border(0.5.dp, Color.Red.copy(alpha = 0.5f)) + .background(Color.Red.copy(alpha = 0.05f)) + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(4) { index -> + Box( + modifier = Modifier + .fillMaxWidth(if (index % 2 == 0) 0.95f else 0.85f) + .height(2.dp) + .background(Color.Red.copy(alpha = 0.2f)) + ) + Spacer(modifier = Modifier.height(pR / 4)) + } + } + } + } +} + +@Composable +private fun AccoladeShape(modifier: Modifier) { + Canvas(modifier = modifier) { + // Le path que tu as fourni (normalisé sur une grille de 24x24 environ) + val pathData = "M14 21C12.8954 21 12 20.1046 12 19V15.3255C12 14.8363 12 14.5917 11.9447 14.3615C11.8957 14.1575 11.8149 13.9624 11.7053 13.7834C11.5816 13.5816 11.4086 13.4086 11.0627 13.0627L10 12L11.0627 10.9373C11.4086 10.5914 11.5816 10.4184 11.7053 10.2166C11.8149 10.0376 11.8957 9.84254 11.9447 9.63846C12 9.40829 12 9.1637 12 8.67452V5C12 3.89543 12.8954 3 14 3" + + val path = PathParser().parsePathString(pathData).toPath() + val pathBounds = path.getBounds() + val scaleX = size.width / pathBounds.width + val scaleY = size.height / pathBounds.height + + drawContext.canvas.save() + drawContext.canvas.scale(scaleX, scaleY) + drawContext.canvas.translate(-pathBounds.left, -pathBounds.top) + + drawPath( + path = path, + color = Color.Black, + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = 0.552f, + cap = androidx.compose.ui.graphics.StrokeCap.Round, + join = androidx.compose.ui.graphics.StrokeJoin.Round + ) + ) + drawContext.canvas.restore() + } +} \ No newline at end of file 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 fc1eb0e..c61016b 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -183,6 +183,20 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _activeIndex.value = clamped } } + private val _gridCount = MutableStateFlow(0) + val gridCount: StateFlow = _gridCount.asStateFlow() + + fun addGridCount(nbGrid: Int) { + _gridCount.value += nbGrid + } + fun descGridCount(nbGrid: Int) { + if(_gridCount.value > 0) { + _gridCount.value -= nbGrid + } + } + fun resetGridCount() { + _gridCount.value = 0 + } fun seekToGrid(gridIndex: Int) { _mediaPlayer?.seekToGrid(gridIndex) } @@ -327,6 +341,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode fun loadNewSong(newMidiFile: String) { _mediaPlayer?.stop() _mediaPlayer?.release() + _stanza.value = 1 _mediaPlayer = null _isPos.value = true _isPlay.value = false @@ -436,6 +451,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _tuoTimestamps.value = emptyList() updateSearchTxt("") tempTimeUnitObjectList.clear() + resetGridCount() } fun lastTUO(): TimeUnitObject? { @@ -464,7 +480,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode } catch (e: NumberFormatException) { _stanza.value = 0 } - loadNewSong("whawyd3.mid") } fun setSongKey(theSongKey: String) { diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt index 5ded061..02d6748 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt @@ -14,7 +14,6 @@ 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 @@ -388,7 +387,6 @@ private fun drawTUOUnderlines( } // ── 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) { @@ -450,12 +448,17 @@ private fun drawNoteWithSubscript( val ch = text[i] val str = ch.toString() cs.beginText() - cs.setFont(font, fontSize) + when(ch) { + '₁', '₂', '₃', '¹', '²', '³' -> { + cs.setFont(font, fontSize * 0.6f) + } else -> { + cs.setFont(font, fontSize) + } + } cs.newLineAtOffset(curX, y) cs.showText(str.sanitize()) cs.endText() curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize -// } i++ } } @@ -465,7 +468,10 @@ private fun drawHeader( cs: PDPageContentStream, noteFont: PDType0Font, lyricFont: PDType0Font?, - pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float, + bolderFont: PDType0Font?, + emmentalerfFont: PDType0Font?, + pageWidth: Float, pageHeight: Float, marginX: Float, marginY: Float, + usableWidth: Float, songTitle: String, songAuthor: String, songRhythm: String, songComposer: String, songKey: String, measure: String, stanza: Int, nbStanza: Int @@ -473,20 +479,28 @@ private fun drawHeader( val titleFont = lyricFont ?: noteFont val normalFont = lyricFont ?: noteFont val smallFont = noteFont + val emmentalerFont = emmentalerfFont ?: noteFont + val boldFont = bolderFont ?: noteFont fun tw(f: PDType0Font, sz: Float, t: String) = try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f } + // ── Y calculés depuis marginY au lieu de valeurs fixes ──────────────────── + val titleY = pageHeight - marginY - 5f + val line2Y = pageHeight - marginY - 21f + val line3Y = pageHeight - marginY - 33f + val sepY = line3Y - 5f + // 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.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, titleY) + cs.showText(songTitle.sanitize()) cs.endText() - // Ligne 2 — Author | Composer (droite) - val line2Y = pageHeight - 64f; val sz2 = 9f + // Ligne 2 — Author | Composer (droite) + val sz2 = 9f cs.beginText(); cs.setFont(normalFont, sz2) cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText() @@ -497,35 +511,71 @@ private fun drawHeader( 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()}" + // Ligne 3 — Key | Measure | Rhythm + val sz3 = 10f + val keyTxt = "Dô dia " cs.beginText(); cs.setFont(smallFont, sz3) - cs.newLineAtOffset(marginX, line3Y); cs.showText(keyTxt); + cs.newLineAtOffset(marginX, line3Y); + cs.showText(keyTxt); cs.endText() - val keyW = tw(smallFont, sz3, keyTxt) - val measureTxt = "${measure.sanitize()}" + val keyW_2 = tw(smallFont, sz3, keyTxt) + val keyTxt2 = "${songKey.sanitize()}" + cs.beginText(); cs.setFont(boldFont, sz3+2f) + cs.newLineAtOffset(marginX + keyW_2 + 3f, line3Y); + cs.showText(keyTxt2); + cs.endText() + + val keyW = tw(boldFont, sz3, keyTxt+keyTxt2) + 10f + val sz32 = 14f + val splitMeaRgx = """(\d+/\d+)\s+(.*)""".toRegex() + val matchRes = splitMeaRgx.matchEntire(measure) + var timeSignature = "" + var titleSignature = "" + if(matchRes != null) { + timeSignature = matchRes.groupValues[1] + titleSignature = matchRes.groupValues[2] + } + val timesig = timeSignature.toCharArray() + + var ksW = keyW + timesig.forEach { c -> + cs.beginText(); + if(c == '/') { + cs.setFont(normalFont, sz32) + cs.newLineAtOffset(marginX + ksW + 10f, line3Y) + ksW += tw(normalFont, sz32, c.toString()) + } else { + cs.setFont(emmentalerFont, sz32) + cs.newLineAtOffset(marginX + ksW + 10f, line3Y) + ksW += tw(emmentalerFont, sz32, c.toString()) + } + cs.showText(c.toString()); + cs.endText() + } cs.beginText(); cs.setFont(smallFont, sz3) - cs.newLineAtOffset(marginX + keyW + 10f, line3Y) - cs.showText("${measure.sanitize()}"); + cs.newLineAtOffset(marginX + ksW + 15f, line3Y) + cs.showText("${titleSignature}"); cs.endText() val rhythmW = tw(normalFont, sz3, songRhythm.sanitize()) - cs.beginText(); cs.setFont(normalFont, sz2) + cs.beginText(); + cs.setFont(normalFont, sz2) cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y) - cs.showText(songRhythm.sanitize()); cs.endText() - + 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() + cs.setStrokingColor(0.6f, 0.6f, 0.6f); + cs.setLineWidth(0.4f) + cs.moveTo(marginX, sepY); + cs.lineTo(marginX + usableWidth, sepY); + cs.stroke() } - actual fun rememberPdfExportAction( scope: CoroutineScope, fileRepository: FileRepository, @@ -538,13 +588,14 @@ actual fun rememberPdfExportAction( songAut: String, songComp: String, songRythm: String -): () -> Unit { - return { +): (PrintSettings) -> Unit { + return { settings: PrintSettings -> 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 + nbStanza, songKey, songAut, songComp, songRythm, + settings ) val chosenPath = withContext(Dispatchers.Main) { fileRepository.pickSavePath(computedFileName) @@ -565,7 +616,8 @@ private suspend fun generatePdfToBytes( songKey: String, songAut: String, songComp: String, - songRythm: String + songRythm: String, + settings: PrintSettings = defaultPrintSettings() ): Pair { val tuoList = gridData.tuoList.drop(1) @@ -578,20 +630,21 @@ private suspend fun generatePdfToBytes( .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 + val markerHeight = settings. markerHeight + val noteFontSize = settings.noteFontSize + val lyricFontSize = settings.lyricFontSize + val noteLineH = settings.noteLineH + val lyricLineH = settings.lyricLineH + val extraSylLineH = settings.extraSylLineH + val adjustedMargin = settings.adjustedMargin + val interRowGap = settings.interRowGap + val marginX = settings.marginX + val marginY = settings.marginY + val headerH = settings.headerH + val pageWidth = settings.pageWidth + val pageHeight = settings.pageHeight + val usableWidth = pageWidth - 2 * marginX + val customNbGrid = settings.nbGridOnMeasure // ── Polices ──────────────────────────────────────────────────── val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf") @@ -614,17 +667,20 @@ private suspend fun generatePdfToBytes( ) // ── 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 colCount = if (customNbGrid == 0) { + ((usableWidth / bestColW).toInt()).coerceAtLeast(1) + } else { + customNbGrid + } + val finalColWidth = usableWidth / colCount + //println("ici Colc est $colCount") + val colWidth = finalColWidth val maxSylsSize = tuoList.maxOfOrNull { tuo -> // lyrics Alt @@ -635,7 +691,7 @@ private suspend fun generatePdfToBytes( // ── Fonctions de dessin ──────────────────────────────────── fun newPage(): PDPageContentStream { - val page = PDPage(PDRectangle.A4) + val page = PDPage(settings.pdRectangle) doc.addPage(page) val cs = PDPageContentStream(doc, page) @@ -832,8 +888,8 @@ private suspend fun generatePdfToBytes( var y = pageHeight - marginY - headerH drawHeader( - cs, customFont, lyricFont as? PDType0Font, - pageWidth, pageHeight, marginX, usableWidth, + cs, customFont, lyricFont as? PDType0Font, markerBoldFont, markerEmmetFont, + pageWidth, pageHeight, marginX, marginY, usableWidth, songTitle = songTitle, songAuthor = songAut, songRhythm = songRythm, @@ -844,9 +900,18 @@ private suspend fun generatePdfToBytes( nbStanza = nbStanza ) - val rows = tuoList.chunked(colCount) + val allRows = tuoList.chunked(colCount) + // si dernier portée n'a qu'une séparateur pas de note on dessine sur l'avant dernier + val hasTrailingBarlineRow = allRows.size > 1 && + allRows.last().size == 1 && + allRows.last().first().noteAsMultiString().trim().isEmpty() && + allRows.last().first().sep0 in listOf("|", "/") + + val rows = if (hasTrailingBarlineRow) allRows.dropLast(1) else allRows rows.forEachIndexed { rowIdx, rowTuos -> +// println("Portée n°: $rowIdx: && rs ${rows.size}") + val isLastVisualRow = rowIdx == rows.size - 1 val maxNoteLines = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1 val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0 @@ -871,13 +936,11 @@ private suspend fun generatePdfToBytes( // 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 != "–" @@ -901,7 +964,6 @@ private suspend fun generatePdfToBytes( 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, "")) @@ -924,10 +986,10 @@ private suspend fun generatePdfToBytes( 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 hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4)) val isUnisson = prefixInts.containsAll(listOf(1, 2, 3, 4)) // Logique simplifiée pour le calcul des spacings (Duo consécutif) @@ -945,19 +1007,14 @@ private suspend fun generatePdfToBytes( 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 + 1 -> if (syl0HasRealText && prefixInts.contains(1)) 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 @@ -972,7 +1029,7 @@ private suspend fun generatePdfToBytes( val customSpacings = spacings.toList() - /*if (rowIdx == 7 || (rowIdx == 8)) { + /*if (rowIdx == 2 || (rowIdx == 2)) { println("╠══════════════════════════════════════════════════════════════╣") println("║ customSpacings calculés : $customSpacings ") println("╚══════════════════════════════════════════════════════════════╝") @@ -1014,7 +1071,6 @@ private suspend fun generatePdfToBytes( } } } - println("\n" + "═".repeat(64)) }*/ // println("\n[FIN SCAN ROW $rowIdx]") @@ -1034,10 +1090,10 @@ private suspend fun generatePdfToBytes( drawHairPinForTUO( cs = cs, tuo = tuo, - gridColumnCount = colCount, // tu as déjà colCount + gridColumnCount = colCount, colX = x, colWidth = colWidth, - markerY = y, // ton y actuel + markerY = y, noteLineH = noteLineH ) } @@ -1093,6 +1149,27 @@ private suspend fun generatePdfToBytes( rowTuos.forEachIndexed { colIdx, tuo -> val x = marginX + colIdx * colWidth + // on dessine à droite + if (hasTrailingBarlineRow && isLastVisualRow && colIdx == rowTuos.size - 1) { + val finalX = x + colWidth + val sepToDraw = allRows.last().first().sep0 + + cs.setStrokingColor(0f, 0f, 0f) + cs.setLineWidth(0.8f) + + val sepW = textWidth(customFont, noteFontSize, "|") + cs.moveTo(finalX - sepW, lineTop) + cs.lineTo(finalX - sepW, lineBottom) + cs.stroke() + + if (sepToDraw == "/") { + cs.moveTo(finalX - (sepW * 0.5f), lineTop) + cs.lineTo(finalX - (sepW * 0.5f), lineBottom) + cs.stroke() + } + } + + val sepW = textWidth(customFont, noteFontSize, "|") val noteX = when (tuo.sep0) { "/", "|" -> { @@ -1110,8 +1187,7 @@ private suspend fun generatePdfToBytes( val noteLines = tuo.noteAsMultiString().split("\n") - - // println("\n=== ROW0 col[$colIdx] sep0='${tuo.sep0}' noteX=$noteX ===") +// println("\n=== ROW0 col[$colIdx] sep0='${tuo.sep0}' note=${noteLines[0]} ===") // println(" noteAsMultiString raw = ${tuo.noteAsMultiString().replace("\n", "\\n")}") // println(" noteLines.size = ${noteLines.size}") noteLines.forEachIndexed { li, line -> @@ -1201,133 +1277,101 @@ private suspend fun generatePdfToBytes( // 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 - + var hasLyrics = false + allTemps.forEachIndexed { sIdx, syllables -> 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() + if(syl.isNotEmpty()) hasLyrics=true } - }*/ - 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() } +// println("hasL? = $hasLyrics") + if(hasLyrics) { + 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 } - allTemps.forEachIndexed { sIdx, syllables -> - val x = marginX + sIdx * colWidth - val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x + cs.beginText() + cs.setFont(stanzaNumFont, stanzaNumSize) + cs.setNonStrokingColor(Color.BLACK) + cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric) + cs.showText(stanzaNumTxt) + cs.endText() - // 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() + 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 { - // 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) + // Calcul standard pour les espaces vides entre les notes + val vLi = getVirtualLineIndex(targetIdx, customSpacings) + notesStartY - ((vLi + 1) * noteLineH) + 2f + } - val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || - cleanSyl0.all { it == '―' || it == '-' || it == '—' } + // 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) - val isAllZeroSpacing = customSpacings.all { it == 0 } + // 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() + } - 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) + + 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() @@ -1335,83 +1379,98 @@ private suspend fun generatePdfToBytes( cs.newLineAtOffset(lyricX, currentYLyric) cs.showText(spacedSyl.sanitize()) cs.endText() - - } else if (isAllZeroSpacing) { - // ── CAS SIMPLE : pas d'espace entre notes → syl1+ descend sous syl0 ── + } 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) - if (cleanSyl.isNotBlank()) { - val thisLineY = currentYLyric - lineIdx * extraSylLineH + + val syl0LyricsIsFree = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || + cleanSyl0.all { it == '―' || it == '-' || it == '—' } + + val isAllZeroSpacing = customSpacings.all { it == 0 } + + if (prefixes.contains(1) && isAllZeroSpacing && syl0LyricsIsFree) { + println("DEBUG: Force SYL1 sur ligne principale (Rouge) -> $cleanSyl") 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.saveGraphicsState() 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.setNonStrokingColor(Color.RED) + cs.newLineAtOffset(lyricX, currentYLyric) 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) + cs.restoreGraphicsState() - val rawSyl0 = syllables.getOrNull(0) ?: "" - val cleanSyl0 = cleanSyllable(rawSyl0) - val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) || - cleanSyl0.all { it == '―' || it == '-' || it == '—' } + } 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 hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) && - !cleanSyl.all { it == '―' || it == '-' } - val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) || - cleanSyl.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 + 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) + // --- CAS SPÉCIAL : LE PRÉFIXE "1." (SOPRANO) --- + if (voiceNum == 1) { + 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) + } } } } @@ -1419,29 +1478,29 @@ private suspend fun generatePdfToBytes( } } } - } -// println("╚══════════════════════════════════════════════════════════") + // 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 + 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 + // 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 @@ -1494,7 +1553,7 @@ private suspend fun generatePdfToBytes( // println(" DC[$tuoIdx]: '$dcPart'") } if (tuo.pTemplate.markerToString().contains("DC")) { - // println(" 🎯 DC MARQUEUR[$tuoIdx]") + // println(" DC MARQUEUR[$tuoIdx]") } } @@ -1509,15 +1568,14 @@ private suspend fun generatePdfToBytes( val dcPart = syls.drop(1).joinToString(" ") { it.trim() } - // ✅ 2e : NORMAL (si existe) + // NORMAL (si existe) if (normal.isNotEmpty()) { result.append(normal).append(" ") - // println(" → AJOUT NORMAL[$tuoIdx]: '$normal'") } - // ✅ 1er : CROCHET DC (TOUJOURS, même si normal vide) + // CROCHET DC (TOUJOURS, même si normal vide) if (dcPart.isNotEmpty()) { - result.append("[$dcPart] ") - // println(" 🟦 CROCHET DC[$tuoIdx]: '[$dcPart]'") + // dcPart + result.append("[${cleanSyllable(dcPart)}] ") } // 3. DC MARQUEUR (parenthèses) @@ -1739,7 +1797,7 @@ private suspend fun generatePdfToBytes( os.contains("win") -> Runtime.getRuntime().exec(arrayOf("explorer", outputDir)) } emmentalerTTF.close() - println("✅ PDF : $outputPath")*/ + println(" PDF : $outputPath")*/ val computedFileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf" // Sérialiser en mémoire diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt new file mode 100644 index 0000000..81a82e6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PrintSettings.kt @@ -0,0 +1,51 @@ +package mg.dot.feufaro.pdf + +import org.apache.pdfbox.pdmodel.common.PDRectangle + +actual data class PrintSettings actual constructor( + actual val pageSize : PageSize, + actual val orientation : PageOrientation, + actual val markerHeight : Float, + actual val noteFontSize : Float, + actual val lyricFontSize : Float, + actual val noteLineH : Float, + actual val lyricLineH : Float, + actual val extraSylLineH : Float, + actual val adjustedMargin : Float, + actual val interRowGap : Float, + actual val marginX : Float, + actual val marginY : Float, + actual val headerH : Float, + actual val nbGridOnMeasure: Int +) { + val pdRectangle: PDRectangle + get() { + val base = when (pageSize) { + PageSize.A4 -> PDRectangle.A4 + PageSize.A3 -> PDRectangle.A3 + PageSize.LETTER -> PDRectangle.LETTER + } + return if (orientation == PageOrientation.PORTRAIT) base + else PDRectangle(base.height, base.width) + } + + actual val pageWidth : Float get() = pdRectangle.width + actual val pageHeight : Float get() = pdRectangle.height +} + +actual fun defaultPrintSettings(): PrintSettings = PrintSettings( + pageSize = PageSize.A4, + orientation = PageOrientation.PORTRAIT, + markerHeight = 16f, + noteFontSize = 12f, + lyricFontSize = 12.5f, + noteLineH = 17f, + lyricLineH = 15f, + extraSylLineH = 15f, + adjustedMargin = 14f, + interRowGap = 22f, + marginX = 42.5f, + marginY = 42.5f, + headerH = 55f, + nbGridOnMeasure = 0 +) \ No newline at end of file