Add btn to Export pdf & customize print settings

This commit is contained in:
hasinarak3@gmail.com 2026-04-13 11:58:06 +03:00
parent b3d7429b59
commit 1d35b9227d
10 changed files with 1394 additions and 347 deletions

View file

@ -69,9 +69,9 @@ private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String):
private fun bestColWidthPt(
tuoList: List<TimeUnitObject>,
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)
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<ByteArray, String> {
val tuoList = gridData.tuoList.drop(1)
@ -370,20 +429,21 @@ private suspend fun generatePdfToBytes(
fun getVoicesFromPrefix(syl: String): List<Int> =
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)

View file

@ -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
)

View file

@ -16,4 +16,4 @@ expect fun rememberPdfExportAction(
songAut: String,
songComp: String,
songRythm: String
): () -> Unit
): (PrintSettings) -> Unit

View file

@ -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

View file

@ -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

View file

@ -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<PrintSettings?>(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(

View file

@ -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<String>,
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<Float>,
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()
}
}

View file

@ -183,6 +183,20 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_activeIndex.value = clamped
}
}
private val _gridCount = MutableStateFlow(0)
val gridCount: StateFlow<Int> = _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) {

View file

@ -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()
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
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<ByteArray, String> {
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 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<Int> {
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,6 +1277,15 @@ private suspend fun generatePdfToBytes(
// println("║ lyricLineH = $lyricLineH")
// Numéro de stanza
var hasLyrics = false
allTemps.forEachIndexed { sIdx, syllables ->
syllables.forEachIndexed { lineIdx, syl ->
if(syl.isNotEmpty()) hasLyrics=true
}
}
// println("hasL? = $hasLyrics")
if(hasLyrics) {
val stanzaNumTxt = "$currentStanza."
val stanzaNumSize = lyricFontSize * 0.85f
val stanzaNumFont = if (lyricFont is PDType0Font) lyricFont else customFont
@ -1215,25 +1300,6 @@ private suspend fun generatePdfToBytes(
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
@ -1320,21 +1386,25 @@ private suspend fun generatePdfToBytes(
val cleanSyl0 = cleanSyllable(rawSyl0)
val cleanSyl = cleanSyllable(rawSyl)
val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
val syl0LyricsIsFree = !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)
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.RED)
cs.newLineAtOffset(lyricX, currentYLyric)
cs.showText(spacedSyl.sanitize())
cs.endText()
cs.restoreGraphicsState()
} else if (isAllZeroSpacing) {
// ── CAS SIMPLE : pas d'espace entre notes → syl1+ descend sous syl0 ──
@ -1359,8 +1429,6 @@ private suspend fun generatePdfToBytes(
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) &&
@ -1374,17 +1442,7 @@ private suspend fun generatePdfToBytes(
// --- 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)) {
if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) {
drawInOverrideSlot(
cs,
0,
@ -1443,6 +1501,7 @@ private suspend fun generatePdfToBytes(
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

View file

@ -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
)