From fed95c99d4d977a4146c810f82ab3b657736dd0b Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 6 May 2026 20:28:32 +0200 Subject: [PATCH] EditPageScreen: apply output quad --- .../java/org/fairscan/app/MainActivity.kt | 2 +- .../java/org/fairscan/app/MainViewModel.kt | 20 ++++- .../org/fairscan/app/data/DocumentMetadata.kt | 2 + .../org/fairscan/app/data/ImageRepository.kt | 83 +++++++++++++++---- .../fairscan/app/data/ImageTransformations.kt | 10 ++- .../fairscan/app/domain/ExportPreparation.kt | 6 +- .../main/java/org/fairscan/app/domain/Page.kt | 10 +-- .../fairscan/app/platform/ImageProcessor.kt | 13 ++- .../java/org/fairscan/app/ui/PreviewUtils.kt | 2 +- .../app/ui/screens/document/DocumentScreen.kt | 2 +- .../app/ui/screens/edit/EditPageScreen.kt | 20 +---- .../fairscan/app/data/ImageRepositoryTest.kt | 33 ++++---- 12 files changed, 137 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 0b56f1a..033606c 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -185,7 +185,7 @@ class MainActivity : ComponentActivity() { onLoad = { id -> viewModel.loadCropInitialState(id)}, initState = cropInitialState, navigation = navigation, - onUpdatePageQuad = { id, quad, onComplete -> }, + onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) }, ) } is Screen.Main.Document -> { diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index b3b6160..c42b749 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -49,6 +49,7 @@ import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.Quad import kotlin.math.min @OptIn(ExperimentalCoroutinesApi::class) @@ -189,6 +190,22 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { } } + fun setCurrentPageUserQuad(userQuad: Quad) { + viewModelScope.launch { + val currentPage = currentPage() + val totalRotation = currentPage.totalRotation() + val rotateIterations = (4 - totalRotation.degrees / 90) % 4 + val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1)) + _loadingPageId.value = currentPage.id + val pages = withContext(Dispatchers.IO) { + imageRepository.setUserQuad(currentPage.id, newQuad) + imageRepository.pages() + } + _pages.value = pages + _loadingPageId.value = null + } + } + private fun currentPage(): ScanPage { val index = _currentPageIndex.value val pages = _pages.value @@ -234,8 +251,7 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { ?: return@launch val metadata = page.metadata - val baseRotation = metadata?.baseRotation ?: Rotation.R0 - val rotation = baseRotation.add(page.manualRotation) + val rotation = page.totalRotation() val bitmap = withContext(Dispatchers.IO) { val source = imageRepository.source(page.id) diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt index 7e92075..9314330 100644 --- a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -40,6 +40,8 @@ data class PageV2( val baseRotationDegrees: Int = 0, val manualRotationDegrees: Int = 0, val quad: NormalizedQuad? = null, + val quadVersion: Int = 0, + val userQuad: NormalizedQuad? = null, val isColored: Boolean? = null, val colorMode: ColorMode? = null, ) diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 7089cd1..e388b39 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -27,7 +27,6 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey @@ -91,7 +90,7 @@ class ImageRepository( return when { metadataPages != null -> metadataPages - .filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk } + .filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk } .toMutableList() else -> filesOnDisk @@ -135,7 +134,7 @@ class ImageRepository( pages.pages().mapNotNull { runCatching { val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees) - ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata()) + ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata()) }.getOrNull() } } @@ -143,7 +142,7 @@ class ImageRepository( suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = mutex.withLock { val id = "${System.currentTimeMillis()}" - val key = PageViewKey(id, Rotation.R0, colorMode) + val key = PageViewKey(id, Rotation.R0, colorMode, 0) processedImageFile(key).writeBytes(processed.bytes) sourceFile(id).writeBytes(source.bytes) pages.addOrReplace( @@ -162,18 +161,64 @@ class ImageRepository( } suspend fun setColorMode(id: String, colorMode: ColorMode) { - val key = PageViewKey(id, Rotation.R0, colorMode) - val processedFile = processedImageFile(key) - val metadata = mutex.withLock { pages.get(id)?.toMetadata() } + updatePage(id) { page, metadata -> + PageUpdate( + updatedPage = page.copy(colorMode = colorMode), + normalizedQuad = metadata.normalizedQuad, + colorMode = colorMode, + ) + } + } + + suspend fun setUserQuad(id: String, newQuad: Quad) { + updatePage(id) { page, metadata -> + PageUpdate( + updatedPage = page.copy( + quadVersion = page.quadVersion + 1, + userQuad = newQuad.toSerializable(), + ), + normalizedQuad = newQuad, + colorMode = page.colorMode ?: metadata.autoColorMode, + ) + } + } + + private data class PageUpdate( + val updatedPage: PageV2, + val normalizedQuad: Quad, + val colorMode: ColorMode, + ) + + private suspend fun updatePage( + id: String, + buildUpdate: (PageV2, PageMetadata) -> PageUpdate + ) { + val page = mutex.withLock { pages.get(id) } + val metadata = page?.toMetadata() ?: return val sourceFile = sourceFile(id) - if (metadata == null || !sourceFile.exists()) + if (!sourceFile.exists()) return + val update = buildUpdate(page, metadata) + val key = PageViewKey( + pageId = id, + rotation = Rotation.R0, + colorMode = update.colorMode, + quadVersion = update.updatedPage.quadVersion + ) + + val processedFile = processedImageFile(key) val job = processingJobs.computeIfAbsent(key) { scope.async(Dispatchers.IO) { if (!processedFile.exists()) { val sourceJpeg = Jpeg(sourceFile.readBytes()) - val processedJpeg = transformations.process(sourceJpeg, metadata, colorMode) + val processedJpeg = + transformations.process( + sourceJpeg, + normalizedQuad = update.normalizedQuad, + baseRotation = metadata.baseRotation, + colorMode = update.colorMode + ) processedFile.writeBytes(processedJpeg.bytes) } } @@ -185,7 +230,7 @@ class ImageRepository( } mutex.withLock { - pages.update(id) { it.copy(colorMode = colorMode) } + pages.update(id) { update.updatedPage } saveMetadata() } } @@ -248,14 +293,18 @@ class ImageRepository( // --- Other operations --- - private fun processedImageFileName(id: String, colorMode: ColorMode?) : String = - if (colorMode == null) - "${id}.jpg" - else - "${id}.${colorMode.name.lowercase()}.jpg" + private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String { + val sb = StringBuilder(id) + if (colorMode != null) + sb.append(".").append(colorMode.name.lowercase()) + if (quadVersion > 0) + sb.append(".q").append(quadVersion) + sb.append(".jpg") + return sb.toString() + } private fun processedImageFile(key: PageViewKey) : File = - File(processedDir, processedImageFileName(key.pageId, key.colorMode)) + File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion)) private fun sourceFile(id: String): File = File(sourceDir, "$id.jpg") @@ -352,7 +401,7 @@ fun NormalizedQuad.toQuad(): Quad = fun PageV2.toMetadata(): PageMetadata? { if (quad == null || isColored == null) return null return PageMetadata( - quad.toQuad(), + (userQuad ?: quad).toQuad(), Rotation.fromDegrees(baseRotationDegrees), if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE ) diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index 3d55c52..ef42a8b 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -15,8 +15,9 @@ package org.fairscan.app.data import org.fairscan.app.domain.Jpeg -import org.fairscan.app.domain.PageMetadata +import org.fairscan.app.domain.Rotation import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.Quad interface ImageTransformations { @@ -24,6 +25,11 @@ interface ImageTransformations { fun resizeToThumbnail(input: Jpeg): Jpeg - fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg + fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index 9d53ab3..8d6d816 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -48,11 +48,11 @@ suspend fun jpegsForExport( JpegProvider { val source = imageRepository.source(page.id) val metadata = page.metadata - val manualRotation = page.manualRotation val colorMode = page.colorMode if (source != null && metadata != null && colorMode != null) { - val rotation = metadata.baseRotation.add(manualRotation) - processedImage(source, metadata, rotation, colorMode, exportQuality) + val rotation = page.totalRotation() + val normalizedQuad = metadata.normalizedQuad + processedImage(source, normalizedQuad, rotation, colorMode, exportQuality) } else jpeg(page, imageRepository) diff --git a/app/src/main/java/org/fairscan/app/domain/Page.kt b/app/src/main/java/org/fairscan/app/domain/Page.kt index 262a0ed..464853a 100644 --- a/app/src/main/java/org/fairscan/app/domain/Page.kt +++ b/app/src/main/java/org/fairscan/app/domain/Page.kt @@ -27,19 +27,19 @@ data class ScanPage( val id: String, val manualRotation: Rotation, val colorMode: ColorMode?, + val quadVersion: Int, val metadata: PageMetadata?, ) { - fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode) + fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion) + fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0) } data class PageViewKey( val pageId: String, val rotation: Rotation, val colorMode: ColorMode?, -) { - val saveKey: String get() = "$pageId-${rotation.degrees}-$colorMode" -} - + val quadVersion: Int, +) enum class Rotation(val degrees: Int) { R0(0), R90(90), diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index bafdb43..670ccae 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -73,14 +73,19 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { } } - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg { - return processedImage(source, metadata, metadata.baseRotation, colorMode, ExportQuality.BALANCED) + override fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg { + return processedImage(source, normalizedQuad, baseRotation, colorMode, ExportQuality.BALANCED) } } fun processedImage( source: Jpeg, - metadata: PageMetadata, + normalizedQuad: Quad, rotation: Rotation, colorMode: ColorMode, exportQuality: ExportQuality, @@ -90,7 +95,7 @@ fun processedImage( var page: Mat? = null try { sourceMat = source.toMat() - val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) + val quad = normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels) return Jpeg.fromMat(page, exportQuality.jpegQuality) } finally { diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index 97a74fc..2fa0c86 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -30,7 +30,7 @@ fun dummyNavigation(): Navigation { fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { val pageKeys = pageIds.map { - PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR), fakeImage(it, context)) + PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context)) }.toImmutableList() return DocumentUiModel(pageKeys) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index 64d6bfd..34dec8e 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -370,7 +370,7 @@ fun DocumentScreenPreview() { listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(), LocalContext.current ) - val key = PageViewKey("123", Rotation.R0, null) + val key = PageViewKey("123", Rotation.R0, null, 0) DocumentScreen( uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document), navigation = dummyNavigation(), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt index 132c6eb..6d14261 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt @@ -69,7 +69,7 @@ fun EditPageScreen( onLoad: (String) -> Unit, initState: CropInitState, navigation: Navigation, - onUpdatePageQuad: (String, Quad, onComplete: () -> Unit) -> Unit, + onUpdatePageQuad: (Quad) -> Unit, ) { val state = remember { EditPageScreenState() } val quadHandler = remember { QuadEditingHandler() } @@ -125,20 +125,8 @@ fun EditPageScreen( .padding(16.dp) .windowInsetsPadding(WindowInsets.safeDrawing), onConfirm = { - /* - val quad = state.editableQuad - if (quad != null) { - // Reverse the total rotation to get back to original source image coordinates - val rotateIterations = (4 - totalRotation.value.degrees / 90) % 4 - val originalQuad = quad.rotate90(rotateIterations, ImageSize(1, 1)) - onUpdatePageQuad(pageId, originalQuad) { - navigation.back() - } - state.setInitialQuad(quad) - } else { - navigation.back() - } - */ + state.editableQuad?.let { onUpdatePageQuad(it) } + navigation.back() } ) } @@ -336,7 +324,7 @@ fun EditPageScreenPreview() { onLoad = {}, initState = CropInitState.Ready("123",dummyImage, quad), navigation = dummyNavigation(), - onUpdatePageQuad = { _,_,_ -> }, + onUpdatePageQuad = { _ -> }, ) } } diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 29b9f8a..28ed97e 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -26,6 +26,7 @@ import org.assertj.core.api.Assertions.assertThat import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R180 import org.fairscan.app.domain.Rotation.R270 @@ -62,7 +63,7 @@ class ImageRepositoryTest { fun repo( rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input }, resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) }, - process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> + process: (Jpeg, Quad, Rotation, ColorMode) -> Jpeg = { _, _, _, _ -> throw UnsupportedOperationException() } ): ImageRepository { @@ -71,8 +72,12 @@ class ImageRepositoryTest { rotate(input, rotationDegrees) override fun resizeToThumbnail(input: Jpeg): Jpeg = resizeToThumbnail(input) - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = - process(source, metadata, colorMode) + override fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg = process(source, normalizedQuad, baseRotation, colorMode) } return ImageRepository(getFilesDir(), transformations, testScope) @@ -86,7 +91,7 @@ class ImageRepositoryTest { repo.add(jpeg, jpeg(51), metadata1, COLOR) assertThat(repo.imageIds()).hasSize(1) val id = repo.imageIds()[0] - val key = PageViewKey(id, R0, COLOR) + val key = PageViewKey(id, R0, COLOR, 0) assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) @@ -153,7 +158,7 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes) + assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test @@ -182,14 +187,14 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes) + assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test fun `should return null on invalid id`() = runTest { val repo = repo() assertThat(repo.imageIds()).isEmpty() - assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR))).isNull() + assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR, 0))).isNull() } @Test @@ -239,7 +244,7 @@ class ImageRepositoryTest { fun setColorMode_should_process_and_update_metadata() = runTest { val jpeg1 = jpeg(10) val repo = repo( - process = { jpeg ,meta, mode -> + process = { _, _ , _, mode -> assertThat(mode).isEqualTo(GRAYSCALE) jpeg(41) } @@ -249,7 +254,7 @@ class ImageRepositoryTest { val id = repo.pages().first().id repo.setColorMode(id, GRAYSCALE) assertThat(repo.pages().first().colorMode).isEqualTo(GRAYSCALE) - val key = PageViewKey(id, R0, GRAYSCALE) + val key = PageViewKey(id, R0, GRAYSCALE, 0) assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(41)) } @@ -257,7 +262,7 @@ class ImageRepositoryTest { fun setColorMode_should_not_run_twice_in_parallel() = runTest { var processCalls = 0 val repo = repo( - process = { _, _, _ -> + process = { _, _, _, _ -> processCalls++ runBlocking { delay(10) } jpeg(1) @@ -269,7 +274,7 @@ class ImageRepositoryTest { launch { repo.setColorMode(id, GRAYSCALE) } launch { repo.setColorMode(id, GRAYSCALE) } } - val key = PageViewKey(id, R0, GRAYSCALE) + val key = PageViewKey(id, R0, GRAYSCALE, 0) assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(1)) assertThat(processCalls).isEqualTo(1) } @@ -307,11 +312,11 @@ class ImageRepositoryTest { fun metadata() { val quad = quad1.toSerializable() - assertThat(PageV2("1", 0, 0, null,true).toMetadata()).isNull() - assertThat(PageV2("1", 0, 0, quad, null).toMetadata()).isNull() + assertThat(PageV2("1", 0, 0, quad = null, isColored = true).toMetadata()).isNull() + assertThat(PageV2("1", 0, 0, quad).toMetadata()).isNull() listOf(true, false).forEach { isColored -> - val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata() + val metadata = PageV2("1", 0, 0, quad, isColored = isColored).toMetadata() assertThat(metadata).isNotNull() assertThat(metadata!!.autoColorMode).isEqualTo( if (isColored) COLOR else GRAYSCALE