New feature: allow user to rotate a page

This commit is contained in:
Pierre-Yves Nicolas
2025-08-28 20:13:04 +02:00
parent d929c3d9d9
commit 2b3377386f
7 changed files with 161 additions and 6 deletions

View File

@@ -18,7 +18,7 @@ import java.io.File
const val SCAN_DIR_NAME = "scanned_pages" 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 { private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
@@ -39,6 +39,30 @@ class ImageRepository(appFilesDir: File) {
fileNames.add(fileName) 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? { fun getContent(id: String): ByteArray? {
if (fileNames.contains(id)) { if (fileNames.contains(id)) {
val file = File(scanDir, id) val file = File(scanDir, id)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app
import java.io.File
fun interface ImageTransformations {
fun rotate(inputFile: File, outputFile: File, clockwise: Boolean)
}

View File

@@ -110,7 +110,8 @@ class MainActivity : ComponentActivity() {
onStartNew = { onStartNew = {
viewModel.startNewDocument() viewModel.startNewDocument()
viewModel.navigateTo(Screen.Main.Home) }, 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 -> { is Screen.Overlay.About -> {

View File

@@ -58,7 +58,7 @@ class MainViewModel(
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return MainViewModel( return MainViewModel(
ImageSegmentationService(context), ImageSegmentationService(context),
ImageRepository(context.filesDir), ImageRepository(context.filesDir, OpenCvTransformations()),
PdfFileManager( PdfFileManager(
File(context.cacheDir, "pdfs"), File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
@@ -221,6 +221,13 @@ class MainViewModel(
_captureState.value = CaptureState.Idle _captureState.value = CaptureState.Idle
} }
fun rotateImage(id: String, clockwise: Boolean) {
viewModelScope.launch {
imageRepository.rotate(id, clockwise)
_pageIds.value = imageRepository.imageIds()
}
}
fun afterCaptureError() { fun afterCaptureError() {
_captureState.value = CaptureState.Idle _captureState.value = CaptureState.Idle
} }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -29,6 +29,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
@@ -72,6 +74,7 @@ fun DocumentScreen(
pdfActions: PdfGenerationActions, pdfActions: PdfGenerationActions,
onStartNew: () -> Unit, onStartNew: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
) { ) {
// TODO Check how often images are loaded // TODO Check how often images are loaded
val showNewDocDialog = rememberSaveable { mutableStateOf(false) } val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
@@ -112,7 +115,12 @@ fun DocumentScreen(
) )
}, },
) { modifier -> ) { modifier ->
DocumentPreview(document, currentPageIndex, { showDeletePageDialog.value = true }, modifier) DocumentPreview(
document,
currentPageIndex,
{ showDeletePageDialog.value = true },
onRotateImage,
modifier)
if (showNewDocDialog.value) { if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document)) NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
} }
@@ -137,6 +145,7 @@ private fun DocumentPreview(
document: DocumentUiModel, document: DocumentUiModel,
currentPageIndex: MutableIntState, currentPageIndex: MutableIntState,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
val imageId = document.pageId(currentPageIndex.intValue) val imageId = document.pageId(currentPageIndex.intValue)
@@ -170,6 +179,7 @@ private fun DocumentPreview(
) )
} }
} }
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
SecondaryActionButton( SecondaryActionButton(
Icons.Outlined.Delete, Icons.Outlined.Delete,
contentDescription = stringResource(R.string.delete_page), 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 @Composable
private fun BottomBar( private fun BottomBar(
showPdfDialog: MutableState<Boolean>, showPdfDialog: MutableState<Boolean>,
@@ -265,7 +296,8 @@ fun DocumentScreenPreview() {
MutableStateFlow(PdfGenerationUiState()), MutableStateFlow(PdfGenerationUiState()),
{}, {}, {}), {}, {}, {}),
onStartNew = {}, onStartNew = {},
onDeleteImage = { _ -> {} } onDeleteImage = { _ -> },
onRotateImage = { _,_ -> },
) )
} }
} }

View File

@@ -35,7 +35,7 @@ class ImageRepositoryTest {
} }
fun repo(): ImageRepository { fun repo(): ImageRepository {
return ImageRepository(getFilesDir()) return ImageRepository(getFilesDir(), {f1,f2,_->f1.copyTo(f2)})
} }
@Test @Test
@@ -89,4 +89,32 @@ class ImageRepositoryTest {
val repo2 = repo() val repo2 = repo()
assertThat(repo2.imageIds()).isEmpty() 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")
}
} }