diff --git a/app/src/main/java/org/fairscan/app/ImageRepository.kt b/app/src/main/java/org/fairscan/app/ImageRepository.kt index a871d4f..08e83c3 100644 --- a/app/src/main/java/org/fairscan/app/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/ImageRepository.kt @@ -18,7 +18,7 @@ import java.io.File const val SCAN_DIR_NAME = "scanned_pages" -class ImageRepository(appFilesDir: File) { +class ImageRepository(appFilesDir: File, val transformations: ImageTransformations) { private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply { if (!exists()) mkdirs() @@ -39,6 +39,30 @@ class ImageRepository(appFilesDir: File) { fileNames.add(fileName) } + val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg") + + fun rotate(id: String, clockwise: Boolean) { + val originalFile = File(scanDir, id) + if (!originalFile.exists()) { + return + } + idRegex.matchEntire(id)?.let { + val baseId = it.groupValues[1] + val degrees = it.groupValues[3].ifEmpty { "0" }.toInt() + val targetDegrees = (degrees + (if (clockwise) 90 else 270)) % 360 + val rotatedId = if (targetDegrees == 0) "$baseId.jpg" else "$baseId-$targetDegrees.jpg" + val rotatedFile = File(scanDir, rotatedId) + transformations.rotate(originalFile, rotatedFile, clockwise) + if (rotatedFile.exists()) { + val index = fileNames.indexOf(id) + if (index >= 0) { + fileNames[index] = rotatedId + } + delete(id) + } + } + } + fun getContent(id: String): ByteArray? { if (fileNames.contains(id)) { val file = File(scanDir, id) diff --git a/app/src/main/java/org/fairscan/app/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/ImageTransformations.kt new file mode 100644 index 0000000..e3f49fa --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ImageTransformations.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app + +import java.io.File + +fun interface ImageTransformations { + + fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) + +} \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 38a3926..7171353 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -110,7 +110,8 @@ class MainActivity : ComponentActivity() { onStartNew = { viewModel.startNewDocument() viewModel.navigateTo(Screen.Main.Home) }, - onDeleteImage = { id -> viewModel.deletePage(id) } + onDeleteImage = { id -> viewModel.deletePage(id) }, + onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) } ) } is Screen.Overlay.About -> { diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 88ffd1c..9085352 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -58,7 +58,7 @@ class MainViewModel( override fun create(modelClass: Class, extras: CreationExtras): T { return MainViewModel( ImageSegmentationService(context), - ImageRepository(context.filesDir), + ImageRepository(context.filesDir, OpenCvTransformations()), PdfFileManager( File(context.cacheDir, "pdfs"), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), @@ -221,6 +221,13 @@ class MainViewModel( _captureState.value = CaptureState.Idle } + fun rotateImage(id: String, clockwise: Boolean) { + viewModelScope.launch { + imageRepository.rotate(id, clockwise) + _pageIds.value = imageRepository.imageIds() + } + } + fun afterCaptureError() { _captureState.value = CaptureState.Idle } diff --git a/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt new file mode 100644 index 0000000..afd3d9f --- /dev/null +++ b/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app + +import org.opencv.core.Core +import org.opencv.core.Mat +import org.opencv.imgcodecs.Imgcodecs +import java.io.File + +class OpenCvTransformations : ImageTransformations { + override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { + val src: Mat = Imgcodecs.imread(inputFile.absolutePath) + + require (!src.empty()) { "Could not load image from ${inputFile.absolutePath}" } + + val dst = Mat() + Core.rotate(src, dst, + if (clockwise) Core.ROTATE_90_CLOCKWISE else Core.ROTATE_90_COUNTERCLOCKWISE + ) + + if (!Imgcodecs.imwrite(outputFile.absolutePath, dst)) { + throw RuntimeException("Could not write image to ${outputFile.absolutePath}") + } + + src.release() + dst.release() + } +} diff --git a/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt index d68f62d..d7a50e5 100644 --- a/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt @@ -29,6 +29,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.RotateLeft +import androidx.compose.material.icons.automirrored.filled.RotateRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PictureAsPdf @@ -72,6 +74,7 @@ fun DocumentScreen( pdfActions: PdfGenerationActions, onStartNew: () -> Unit, onDeleteImage: (String) -> Unit, + onRotateImage: (String, Boolean) -> Unit, ) { // TODO Check how often images are loaded val showNewDocDialog = rememberSaveable { mutableStateOf(false) } @@ -112,7 +115,12 @@ fun DocumentScreen( ) }, ) { modifier -> - DocumentPreview(document, currentPageIndex, { showDeletePageDialog.value = true }, modifier) + DocumentPreview( + document, + currentPageIndex, + { showDeletePageDialog.value = true }, + onRotateImage, + modifier) if (showNewDocDialog.value) { NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document)) } @@ -137,6 +145,7 @@ private fun DocumentPreview( document: DocumentUiModel, currentPageIndex: MutableIntState, onDeleteImage: (String) -> Unit, + onRotateImage: (String, Boolean) -> Unit, modifier: Modifier, ) { val imageId = document.pageId(currentPageIndex.intValue) @@ -170,6 +179,7 @@ private fun DocumentPreview( ) } } + RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter)) SecondaryActionButton( Icons.Outlined.Delete, contentDescription = stringResource(R.string.delete_page), @@ -193,6 +203,27 @@ private fun DocumentPreview( } } +@Composable +fun RotationButtons( + imageId: String, + onRotateImage: (String, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier = modifier.padding(4.dp)) { + SecondaryActionButton( + icon = Icons.AutoMirrored.Default.RotateLeft, + contentDescription = "Rotate left", + onClick = { onRotateImage(imageId, false) } + ) + Spacer(Modifier.width(8.dp)) + SecondaryActionButton( + icon = Icons.AutoMirrored.Default.RotateRight, + contentDescription = "Rotate right", + onClick = { onRotateImage(imageId, true) } + ) + } +} + @Composable private fun BottomBar( showPdfDialog: MutableState, @@ -265,7 +296,8 @@ fun DocumentScreenPreview() { MutableStateFlow(PdfGenerationUiState()), {}, {}, {}), onStartNew = {}, - onDeleteImage = { _ -> {} } + onDeleteImage = { _ -> }, + onRotateImage = { _,_ -> }, ) } } diff --git a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt index 4ad1bc7..cd450ae 100644 --- a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt @@ -35,7 +35,7 @@ class ImageRepositoryTest { } fun repo(): ImageRepository { - return ImageRepository(getFilesDir()) + return ImageRepository(getFilesDir(), {f1,f2,_->f1.copyTo(f2)}) } @Test @@ -89,4 +89,32 @@ class ImageRepositoryTest { val repo2 = repo() assertThat(repo2.imageIds()).isEmpty() } + + @Test + fun rotate() { + val repo = repo() + repo.add(byteArrayOf(101, 102, 103)) + val id0 = repo.imageIds().last() + val baseId = id0.substring(0, id0.length - 4) + + repo.rotate(id0, true) + val id1 = repo.imageIds().last() + assertThat(id1).isEqualTo("$baseId-90.jpg") + + repo.rotate(id1, true) + val id2 = repo.imageIds().last() + assertThat(id2).isEqualTo("$baseId-180.jpg") + + repo.rotate(id2, true) + val id3 = repo.imageIds().last() + assertThat(id3).isEqualTo("$baseId-270.jpg") + + repo.rotate(id3, true) + val id4 = repo.imageIds().last() + assertThat(id4).isEqualTo("$baseId.jpg") + + repo.rotate(id4, false) + val id5 = repo.imageIds().last() + assertThat(id5).isEqualTo("$baseId-270.jpg") + } }