New feature: allow user to rotate a page
This commit is contained in:
@@ -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)
|
||||
|
||||
23
app/src/main/java/org/fairscan/app/ImageTransformations.kt
Normal file
23
app/src/main/java/org/fairscan/app/ImageTransformations.kt
Normal 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)
|
||||
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -58,7 +58,7 @@ class MainViewModel(
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, 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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean>,
|
||||
@@ -265,7 +296,8 @@ fun DocumentScreenPreview() {
|
||||
MutableStateFlow(PdfGenerationUiState()),
|
||||
{}, {}, {}),
|
||||
onStartNew = {},
|
||||
onDeleteImage = { _ -> {} }
|
||||
onDeleteImage = { _ -> },
|
||||
onRotateImage = { _,_ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user