From 2b3377386fa4419a81bb1bc34a2bef64a6861445 Mon Sep 17 00:00:00 2001
From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com>
Date: Thu, 28 Aug 2025 20:13:04 +0200
Subject: [PATCH] New feature: allow user to rotate a page
---
.../java/org/fairscan/app/ImageRepository.kt | 26 +++++++++++-
.../org/fairscan/app/ImageTransformations.kt | 23 +++++++++++
.../java/org/fairscan/app/MainActivity.kt | 3 +-
.../java/org/fairscan/app/MainViewModel.kt | 9 ++++-
.../app/OpenCvImageTransformations.kt | 40 +++++++++++++++++++
.../org/fairscan/app/view/DocumentScreen.kt | 36 ++++++++++++++++-
.../org/fairscan/app/ImageRepositoryTest.kt | 30 +++++++++++++-
7 files changed, 161 insertions(+), 6 deletions(-)
create mode 100644 app/src/main/java/org/fairscan/app/ImageTransformations.kt
create mode 100644 app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt
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")
+ }
}